
([]);
useNow(1000);
const { state } = useSoundContext();
@@ -71,13 +72,23 @@ export default function SightingHistoryWidget({ className, title }: SightingHist
} = useSightingFeedContext();
const { dispatch } = useAlertHitContext();
- const { sessionStarted, setSessionList, sessionList } = useNPEDContext();
+ const { state: integrationState, dispatch: integrationDispatch } = useIntegrationsContext();
+ const sessionStarted = integrationState.sessionStarted;
+ const sessionPaused = integrationState.sessionPaused;
const processedRefs = useRef
>(new Set());
const hasAutoOpenedRef = useRef(false);
const npedRef = useRef(false);
+ const enqueue = useCallback((sighting: SightingType, kind: HitKind) => {
+ const id = sighting.vrm ?? sighting.ref;
+ if (processedRefs.current.has(id)) return;
+ processedRefs.current.add(id);
+
+ setModalQueue((q) => [...q, { id, sighting, kind }]);
+ }, []);
+
const reduceObject = (obj: SightingType): ReducedSightingType => {
return {
vrm: obj.vrm,
@@ -88,11 +99,12 @@ export default function SightingHistoryWidget({ className, title }: SightingHist
useEffect(() => {
if (sessionStarted) {
if (!mostRecent) return;
+ if (sessionPaused) return;
const reducedMostRecent = reduceObject(mostRecent);
- setSessionList([...sessionList, reducedMostRecent]);
+ integrationDispatch({ type: "ADD", payload: reducedMostRecent });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [mostRecent, sessionStarted, setSessionList]);
+ }, [mostRecent, sessionStarted]);
const onRowClick = useCallback(
(sighting: SightingType) => {
@@ -112,26 +124,15 @@ export default function SightingHistoryWidget({ className, title }: SightingHist
const id = sighting.vrm;
if (processedRefs.current.has(id)) continue;
- const isHot = checkIsHotListHit(sighting);
- const cat = sighting?.metadata?.npedJSON?.["NPED CATEGORY"];
+ const isHotlistHit = checkIsHotListHit(sighting);
+ const npedcategory = sighting?.metadata?.npedJSON?.["NPED CATEGORY"];
+ const isNPED = npedcategory === "A" || npedcategory === "B" || npedcategory === "C";
- if (cat === "A" || cat === "B" || cat === "C") {
- npedSound();
- setSelectedSighting(sighting);
- setSightingModalOpen(true);
- processedRefs.current.add(id);
- break; // stop after one new open per render cycle
- }
-
- if (isHot) {
- hotlistsound();
- setSelectedSighting(sighting);
- setSightingModalOpen(true);
- processedRefs.current.add(id);
- break;
+ if (isNPED || isHotlistHit) {
+ enqueue(sighting, isNPED ? "NPED" : "HOTLIST"); // enqueue ONLY
}
}
- }, [rows, hotlistsound, npedSound, setSightingModalOpen, setSelectedSighting]);
+ }, [rows, enqueue]);
useEffect(() => {
rows?.forEach((obj) => {
@@ -164,22 +165,33 @@ export default function SightingHistoryWidget({ className, title }: SightingHist
});
if (firstNPED) {
- setSelectedSighting(firstNPED);
- npedSound();
- setSightingModalOpen(true);
+ enqueue(firstNPED, "NPED");
+
npedRef.current = true;
}
if (firstHot) {
- setSelectedSighting(firstHot);
- hotlistsound();
- setSightingModalOpen(true);
+ enqueue(firstHot, "HOTLIST");
+
hasAutoOpenedRef.current = true;
}
- }, [hotlistsound, npedSound, setSelectedSighting]);
+ }, [enqueue, hotlistsound, npedSound, rows, setSelectedSighting, setSightingModalOpen]);
+
+ useEffect(() => {
+ if (!isSightingModalOpen && modalQueue.length > 0) {
+ const next = modalQueue[0];
+
+ if (next.kind === "NPED") npedSound();
+ else hotlistsound();
+
+ setSelectedSighting(next.sighting);
+ setSightingModalOpen(true);
+ }
+ }, [isSightingModalOpen, npedSound, hotlistsound, setSelectedSighting, setSightingModalOpen, modalQueue]);
const handleClose = () => {
setSightingModalOpen(false);
+ setModalQueue((q) => q.slice(1));
};
return (
<>
diff --git a/src/components/UI/Header.tsx b/src/components/UI/Header.tsx
index b48c938..c093949 100644
--- a/src/components/UI/Header.tsx
+++ b/src/components/UI/Header.tsx
@@ -1,21 +1,18 @@
import { Link } from "react-router";
import Logo from "/MAV.svg";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import {
- faGear,
- faHome,
- faListCheck,
- faMaximize,
- faMinimize,
- faRotate,
-} from "@fortawesome/free-solid-svg-icons";
+import { faGear, faHome, faListCheck, faMaximize, faMinimize, faRotate } from "@fortawesome/free-solid-svg-icons";
import { useState } from "react";
import SoundBtn from "./SoundBtn";
-import { useNPEDContext } from "../../context/NPEDUserContext";
+import { useIntegrationsContext } from "../../context/IntegrationsContext";
export default function Header() {
const [isFullscreen, setIsFullscreen] = useState(false);
- const { sessionStarted } = useNPEDContext();
+ const { state } = useIntegrationsContext();
+
+ const sessionStarted = state.sessionStarted;
+
+ const sessionPaused = state.sessionPaused;
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
@@ -39,9 +36,13 @@ export default function Header() {
- {sessionStarted && (
-
Session Active
- )}
+
+ {sessionStarted && sessionPaused ? (
+
Session Paused
+ ) : (
+ sessionStarted &&
Session Active
+ )}
+
@@ -59,11 +60,7 @@ export default function Header() {
-
+
diff --git a/src/components/UI/VehicleSessionItem.tsx b/src/components/UI/VehicleSessionItem.tsx
new file mode 100644
index 0000000..a0e8ea2
--- /dev/null
+++ b/src/components/UI/VehicleSessionItem.tsx
@@ -0,0 +1,18 @@
+import clsx from "clsx";
+
+type VehicleSessionItemProps = {
+ sessionNumber: number;
+ textColour: string;
+ vehicleTag: string;
+};
+
+const VehicleSessionItem = ({ sessionNumber, textColour, vehicleTag }: VehicleSessionItemProps) => {
+ return (
+
+ {vehicleTag}
+ {sessionNumber}
+
+ );
+};
+
+export default VehicleSessionItem;
diff --git a/src/context/IntegrationsContext.ts b/src/context/IntegrationsContext.ts
new file mode 100644
index 0000000..a99c596
--- /dev/null
+++ b/src/context/IntegrationsContext.ts
@@ -0,0 +1,14 @@
+import { createContext, useContext, type ActionDispatch } from "react";
+import type { NPEDACTION, NPEDSTATE } from "../types/types";
+
+type IntegrationsValue = {
+ state: NPEDSTATE;
+ dispatch: ActionDispatch<[action: NPEDACTION]>;
+};
+
+export const IntegrationsContext = createContext
(undefined);
+export const useIntegrationsContext = () => {
+ const ctx = useContext(IntegrationsContext);
+ if (!ctx) throw new Error("useNPEDContext must be used within ");
+ return ctx;
+};
diff --git a/src/context/NPEDUserContext.ts b/src/context/NPEDUserContext.ts
deleted file mode 100644
index 7b36b9e..0000000
--- a/src/context/NPEDUserContext.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { createContext, useContext, type SetStateAction } from "react";
-import type { NPEDUser, ReducedSightingType } from "../types/types";
-
-type UserContextValue = {
- user: NPEDUser | null;
- setUser: React.Dispatch>;
- sessionStarted: boolean;
- setSessionStarted: React.Dispatch>;
- sessionList: ReducedSightingType[];
- setSessionList: React.Dispatch>;
-};
-
-export const NPEDUserContext = createContext(
- undefined
-);
-export const useNPEDContext = () => {
- const ctx = useContext(NPEDUserContext);
- if (!ctx)
- throw new Error("useNPEDContext must be used within ");
- return ctx;
-};
diff --git a/src/context/providers/IntegrationsContextProvider.tsx b/src/context/providers/IntegrationsContextProvider.tsx
new file mode 100644
index 0000000..d12540c
--- /dev/null
+++ b/src/context/providers/IntegrationsContextProvider.tsx
@@ -0,0 +1,36 @@
+import { useEffect, useReducer, type ReactNode } from "react";
+import { IntegrationsContext } from "../IntegrationsContext";
+import { useCameraBlackboard } from "../../hooks/useCameraBlackboard";
+import { initialState, reducer } from "../reducers/IntegrationsContextReducer";
+
+type IntegrationsProviderType = {
+ children: ReactNode;
+};
+
+export const IntegrationsProvider = ({ children }: IntegrationsProviderType) => {
+ const [state, dispatch] = useReducer(reducer, initialState);
+ const { mutation } = useCameraBlackboard();
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const result = await mutation.mutateAsync({
+ operation: "VIEW",
+ path: "sessionStats",
+ });
+ if (!result.result || typeof result.result === "string") return;
+
+ dispatch({ type: "UPDATE", payload: result?.result });
+ };
+ fetchData();
+ }, []);
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/context/providers/NPEDUserContextProvider.tsx b/src/context/providers/NPEDUserContextProvider.tsx
deleted file mode 100644
index e76247f..0000000
--- a/src/context/providers/NPEDUserContextProvider.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { useState, type ReactNode } from "react";
-import type { NPEDUser, ReducedSightingType } from "../../types/types";
-import { NPEDUserContext } from "../NPEDUserContext";
-
-type NPEDUserProviderType = {
- children: ReactNode;
-};
-
-export const NPEDUserProvider = ({ children }: NPEDUserProviderType) => {
- const [user, setUser] = useState(null);
- const [sessionStarted, setSessionStarted] = useState(false);
- const [sessionList, setSessionList] = useState([]);
-
- return (
-
- {children}
-
- );
-};
diff --git a/src/context/reducers/IntegrationsContextReducer.ts b/src/context/reducers/IntegrationsContextReducer.ts
new file mode 100644
index 0000000..f740f4c
--- /dev/null
+++ b/src/context/reducers/IntegrationsContextReducer.ts
@@ -0,0 +1,46 @@
+import type { NPEDACTION, NPEDSTATE } from "../../types/types";
+
+export const initialState = {
+ sessionStarted: false,
+ sessionList: [],
+ sessionPaused: false,
+ savedSightings: [],
+ npedUser: null,
+};
+
+export function reducer(state: NPEDSTATE, action: NPEDACTION) {
+ switch (action.type) {
+ case "SESSIONSTART":
+ return {
+ ...state,
+ sessionStarted: action.payload,
+ };
+ case "LOGIN":
+ return {
+ ...state,
+ npedUser: action.payload,
+ };
+ case "LOGOUT":
+ return {
+ ...state,
+ npedUser: action.payload,
+ };
+ case "SESSIONPAUSE":
+ return {
+ ...state,
+ sessionPaused: action.payload,
+ };
+ case "ADD":
+ return {
+ ...state,
+ sessionList: [...state.sessionList, action.payload],
+ };
+ case "UPDATE":
+ return {
+ ...state,
+ sessionList: action.payload,
+ };
+ default:
+ return { ...state };
+ }
+}
diff --git a/src/hooks/useNPEDAuth.ts b/src/hooks/useNPEDAuth.ts
index 619f4e5..31c7ea7 100644
--- a/src/hooks/useNPEDAuth.ts
+++ b/src/hooks/useNPEDAuth.ts
@@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { NPEDFieldType } from "../types/types";
-import { useNPEDContext } from "../context/NPEDUserContext";
+import { useIntegrationsContext } from "../context/IntegrationsContext";
import { useEffect } from "react";
import { CAM_BASE } from "../utils/config";
import { toast } from "sonner";
@@ -42,8 +42,7 @@ async function signIn(loginDetails: NPEDFieldType) {
}),
]);
- if (!frontRes.ok || !rearRes.ok)
- throw new Error("Cannot reach NPED endpoint");
+ if (!frontRes.ok || !rearRes.ok) throw new Error("Cannot reach NPED endpoint");
return {
frontResponse: frontRes.json(),
@@ -73,7 +72,7 @@ async function signOut() {
}
export const useNPEDAuth = () => {
- const { setUser, user } = useNPEDContext();
+ const { dispatch } = useIntegrationsContext();
const signInMutation = useMutation({
mutationKey: ["NPEDSignin"],
@@ -84,7 +83,8 @@ export const useNPEDAuth = () => {
onSuccess: async (data) => {
toast.dismiss();
toast.success("Signed in successfully!");
- setUser(await data.frontResponse);
+
+ dispatch({ type: "LOGIN", payload: await data.frontResponse });
},
onError: (error) => {
toast.dismiss();
@@ -101,7 +101,7 @@ export const useNPEDAuth = () => {
mutationFn: signOut,
onSuccess: () => {
toast.success("Signed out successfully");
- setUser(null);
+ dispatch({ type: "LOGOUT", payload: null });
},
onError: (error) => {
toast.error(`Sign-out failed: ${error.message}`);
@@ -115,11 +115,11 @@ export const useNPEDAuth = () => {
useEffect(() => {
if (fetchdataQuery.isSuccess && fetchdataQuery.data) {
- setUser(fetchdataQuery.data);
+ dispatch({ type: "LOGIN", payload: fetchdataQuery.data });
} else {
- setUser(null);
+ dispatch({ type: "LOGOUT", payload: null });
}
- }, [fetchdataQuery.data, fetchdataQuery.isSuccess, setUser]);
+ }, [dispatch, fetchdataQuery.data, fetchdataQuery.isSuccess]);
useEffect(() => {
if (fetchdataQuery.isError) toast.error(fetchdataQuery.error.message);
@@ -134,8 +134,6 @@ export const useNPEDAuth = () => {
data: signInMutation.data,
fetchdataQueryError: fetchdataQuery.error,
fetchdataQueryLoading: fetchdataQuery.isLoading,
- user,
- setUser,
signOut: signOutMutation.mutate,
};
};
diff --git a/src/types/types.ts b/src/types/types.ts
index 72a2369..45d8c79 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -371,3 +371,26 @@ export type ModemSettingsType = {
password: string;
authenticationType: string;
};
+
+export type HitKind = "NPED" | "HOTLIST";
+
+export type QueuedHit = {
+ id: number | string;
+ sighting: SightingType;
+ kind: HitKind;
+};
+
+export type DedupedSightings = ReducedSightingType[];
+
+export type NPEDSTATE = {
+ sessionStarted: boolean;
+ sessionList: ReducedSightingType[];
+ sessionPaused: boolean;
+ savedSightings: DedupedSightings;
+ npedUser: NPEDUser;
+};
+
+export type NPEDACTION = {
+ type: string;
+ payload: any;
+};
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
index a0fa34d..0e9f1bc 100644
--- a/src/utils/utils.ts
+++ b/src/utils/utils.ts
@@ -149,10 +149,12 @@ export const checkIsHotListHit = (sigthing: SightingType | null) => {
};
export function getHotlistName(obj: HotlistMatches | undefined) {
- if (!obj || Object.values(obj).includes(false)) return;
+ if (!obj) return;
- const keys = Object.keys(obj);
- return keys;
+ const hotlistNames = Object.entries(obj)
+ .filter(([, value]) => value === true)
+ .map(([key]) => key);
+ return hotlistNames;
}
export const getNPEDCategory = (r?: SightingType | null) =>