- {defaultCenter != null && (
-
diff --git a/src/lanco-incidents-app/src/pages/incident-detail/incident-detail.tsx b/src/lanco-incidents-app/src/pages/incident-detail/incident-detail.tsx
index 1122e1d..c142eed 100644
--- a/src/lanco-incidents-app/src/pages/incident-detail/incident-detail.tsx
+++ b/src/lanco-incidents-app/src/pages/incident-detail/incident-detail.tsx
@@ -1,4 +1,3 @@
-import Layout from "containers/layout";
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import { IncidentDetailContent } from "./incident-detail-content";
@@ -7,26 +6,21 @@ import { IncidentRecord } from "models/view-models/incident-record";
import useIncident from "hooks/use-incident";
import { useWebShare } from "hooks/use-web-share";
import { SITE_TITLE } from "constants/app-constants";
+import { useAppLayout } from "containers/app-layout";
-export default function IncidentDetail() {
+export function IncidentDetail() {
const { id } = useParams<"id">();
const navigate = useNavigate();
const { incident } = useIncident({ id });
const webShare = useWebShare();
- const handleGoBack = () => navigate(-1);
+ const handleGoBack = () => navigate("/", { replace: true });
+ const handleShare = () => webShare.share(getShareData(incident));
- const handleShare = () =>
- webShare.share({
- title: SITE_TITLE,
- text: `${incident?.type} (${incident?.subType}) - ${incident?.location}, ${incident?.area}`,
- url: window.location.href,
- });
-
- return (
-
({
+ pageBgStyle: "bg-white",
+ headerLeft: (
{getTitle(incident)}
- }
- >
-
-
+ ),
+ }),
+ [incident]
);
+
+ return
;
}
function getTitle(incident?: IncidentRecord | null): string {
@@ -50,3 +45,17 @@ function getTitle(incident?: IncidentRecord | null): string {
return "Incident";
}
+
+function getShareData(incident?: IncidentRecord | null) {
+ const incidentTypeText = incident?.subType
+ ? `${incident?.type} (${incident?.subType})`
+ : incident?.type;
+
+ const locationText = [incident?.location, incident?.area].join(", ");
+
+ return {
+ title: SITE_TITLE,
+ text: [incidentTypeText, locationText].join(" - "),
+ url: window.location.href,
+ };
+}
diff --git a/src/lanco-incidents-app/src/pages/settings.tsx b/src/lanco-incidents-app/src/pages/settings.tsx
index d42595b..681e378 100644
--- a/src/lanco-incidents-app/src/pages/settings.tsx
+++ b/src/lanco-incidents-app/src/pages/settings.tsx
@@ -1,4 +1,3 @@
-import Layout from "containers/layout";
import React, { useCallback, useEffect, useMemo, useReducer } from "react";
import useSettings, { UseSettingsHook } from "hooks/use-settings";
import _, { chain } from "lodash";
@@ -6,6 +5,7 @@ import PageTitle from "components/page-title";
import SettingsSectionSort from "components/settings/settings-sections/settings-section-sortby";
import { Sort } from "models/view-models/settings-record";
import { useNavigate } from "react-router-dom";
+import { useAppLayout } from "containers/app-layout";
type TypeOfKey
= Type[Key];
@@ -95,6 +95,13 @@ const Settings: React.FC = () => {
navigate(-1);
}, [navigate]);
+ useAppLayout(
+ () => ({
+ headerLeft: Settings,
+ }),
+ []
+ );
+
const handleApply = useCallback(() => {
updateSettings(incidentFilters, sort);
goBack();
@@ -117,59 +124,52 @@ const Settings: React.FC = () => {
}, [originalSort, originalIncidentTypeFilters]);
return (
- Settings}
- >
-
-
-
-
-
-
-
- Filter:
+
+
+
+
+
+
+
Filter:
+ {sortedIncidentFilters.map((type) => (
+
+
- {sortedIncidentFilters.map((type) => (
-
-
-
- ))}
-
+ ))}
-
-
-
-
-
+
+
+
+
+
-
+
);
};
diff --git a/src/lanco-incidents-app/src/providers/geolocation-provider.tsx b/src/lanco-incidents-app/src/providers/geolocation-provider.tsx
deleted file mode 100644
index c6297e9..0000000
--- a/src/lanco-incidents-app/src/providers/geolocation-provider.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import React, {
- PropsWithChildren,
- useCallback,
- useEffect,
- useState,
-} from "react";
-
-type GeolocationPositionStatus =
- | GeolocationPositionError
- | { permissionGranted: true };
-
-interface GeolocationContextState {
- currentPosition?: GeolocationPosition;
- currentStatus?: GeolocationPositionStatus;
-}
-
-export const GeolocationContext = React.createContext<
- GeolocationContextState & { refresh: () => void }
->({
- ...{},
- refresh: () => {},
-});
-
-interface GeolocationProviderProps extends PositionOptions {
- watch?: boolean;
-}
-
-const GeolocationProvider: React.FC<
- PropsWithChildren
-> = ({ children, watch = false, ...positionOptions }) => {
- const [
- { currentPosition, currentStatus },
- dispatch,
- ] = useState({});
-
- const { enableHighAccuracy, maximumAge, timeout } = positionOptions;
-
- const handlePostionChange = (position: GeolocationPosition) => {
- dispatch((prev) => ({
- ...prev,
- currentPosition: position,
- currentStatus: { permissionGranted: true },
- }));
- };
-
- const handlePositionError = (status: GeolocationPositionError) => {
- dispatch((prev) => ({
- ...prev,
- currentStatus: status,
- }));
- };
-
- const processCurrentPosition = useCallback(() => {
- if ("geolocation" in navigator) {
- if (watch) {
- return navigator.geolocation.watchPosition(
- handlePostionChange,
- handlePositionError,
- { enableHighAccuracy, maximumAge, timeout }
- );
- }
-
- navigator.geolocation.getCurrentPosition(
- handlePostionChange,
- handlePositionError,
- { enableHighAccuracy, maximumAge, timeout }
- );
- }
- }, [enableHighAccuracy, maximumAge, timeout, watch]);
-
- useEffect(() => {
- const watchId = processCurrentPosition();
-
- return () => {
- if (watchId != null) {
- navigator.geolocation.clearWatch(watchId);
- }
- };
- }, [processCurrentPosition]);
-
- return (
-
- {children}
-
- );
-};
-
-export default GeolocationProvider;
diff --git a/src/lanco-incidents-app/src/router.tsx b/src/lanco-incidents-app/src/router.tsx
new file mode 100644
index 0000000..05977fb
--- /dev/null
+++ b/src/lanco-incidents-app/src/router.tsx
@@ -0,0 +1,27 @@
+import React from "react";
+import Home from "pages/home";
+import { createBrowserRouter } from "react-router-dom";
+import Settings from "pages/settings";
+import { IncidentDetail } from "pages/incident-detail/incident-detail";
+import AppLayout from "containers/app-layout";
+
+export const router = createBrowserRouter([
+ {
+ path: "/",
+ element: ,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: "settings",
+ element: ,
+ },
+ {
+ path: "incidents/:id",
+ element: ,
+ },
+ ],
+ },
+]);
diff --git a/src/lanco-incidents-app/src/stores/geolocation-store.ts b/src/lanco-incidents-app/src/stores/geolocation-store.ts
new file mode 100644
index 0000000..ac09593
--- /dev/null
+++ b/src/lanco-incidents-app/src/stores/geolocation-store.ts
@@ -0,0 +1,175 @@
+import { TypedEventTarget } from "utils/typed-event-target";
+import { TypedEvent } from "utils/typed-event";
+import {
+ GeolocationStoreConifguration,
+ GeolocationStoreConifgurationRecord,
+} from "../models/view-models/geolocation-store-conifguration-record";
+import { GeolocationStateRecord } from "models/view-models/geolocation-state-record";
+
+enum GeolocationStatus {
+ Initialized,
+ PermissionGranted,
+ PermissionDenied,
+}
+
+interface GeolocationStoreOptions {
+ geolocation?: Geolocation;
+}
+
+export enum GeoplocationEvent {
+ StatusChange = "statusChange",
+ PositionChange = "positionChange",
+ PositionError = "positionError",
+}
+
+class GeoplocationStatusChangeEvent extends TypedEvent(
+ GeoplocationEvent.StatusChange,
+) {}
+
+class GeoplocationPositionChangeEvent extends TypedEvent(
+ GeoplocationEvent.PositionChange,
+) {}
+
+class GeoplocationPositionErrorEvent extends TypedEvent(
+ GeoplocationEvent.PositionError,
+) {}
+
+export class GeolocationStore extends TypedEventTarget<{
+ [GeoplocationEvent.StatusChange]: GeoplocationStatusChangeEvent;
+ [GeoplocationEvent.PositionChange]: GeoplocationPositionChangeEvent;
+ [GeoplocationEvent.PositionError]: GeoplocationPositionErrorEvent;
+}>() {
+ static readonly Default = new GeolocationStore();
+
+ private config = new GeolocationStoreConifgurationRecord();
+ private state = new GeolocationStateRecord();
+
+ private geolocation?: Geolocation;
+ private watchId?: number;
+
+ constructor(options: GeolocationStoreOptions = {}) {
+ super();
+
+ this.handlePermissionStateChange =
+ this.handlePermissionStateChange.bind(this);
+ this.handlePositionError = this.handlePositionError.bind(this);
+ this.handlePostionChange = this.handlePostionChange.bind(this);
+
+ this.geolocation =
+ options.geolocation == null && "geolocation" in navigator
+ ? navigator.geolocation
+ : options.geolocation;
+
+ this.setupPermissionCheck();
+ }
+
+ getSnapshot() {
+ return this.state;
+ }
+
+ setConfig(config: Partial = {}) {
+ const nextConfig = this.config.with(config);
+
+ if (!this.config.equalTo(nextConfig)) {
+ this.checkLocation(nextConfig);
+ this.watchId = this.setupWatchPosition(nextConfig);
+ this.config = nextConfig;
+ }
+ }
+
+ private setupWatchPosition(
+ nextConfig?: GeolocationStoreConifgurationRecord,
+ ): number | undefined {
+ if (
+ this.geolocation == null ||
+ this.state.status !== GeolocationStatus.PermissionGranted
+ ) {
+ return;
+ }
+
+ if (this.watchId != null) {
+ this.geolocation.clearWatch(this.watchId);
+ }
+
+ if (nextConfig == null || nextConfig.watch !== this.config.watch) {
+ const { enableHighAccuracy, maximumAge, timeout } = this.config;
+
+ if (
+ (nextConfig == null && this.config.watch) ||
+ nextConfig?.watch
+ ) {
+ return this.geolocation.watchPosition(
+ this.handlePostionChange,
+ this.handlePositionError,
+ { enableHighAccuracy, maximumAge, timeout },
+ );
+ }
+ }
+ }
+
+ private checkLocation(config?: GeolocationStoreConifgurationRecord): void {
+ if (
+ this.geolocation == null ||
+ this.state.status !== GeolocationStatus.PermissionGranted
+ ) {
+ return;
+ }
+
+ const { enableHighAccuracy, maximumAge, timeout } =
+ config ?? this.config;
+
+ this.geolocation.getCurrentPosition(
+ this.handlePostionChange,
+ this.handlePositionError,
+ { enableHighAccuracy, maximumAge, timeout },
+ );
+ }
+
+ private setupPermissionCheck() {
+ navigator.permissions
+ .query({ name: "geolocation" })
+ .then((result: PermissionStatus) => {
+ result.onchange = () =>
+ this.handlePermissionStateChange(result.state);
+
+ this.handlePermissionStateChange(result.state);
+ });
+ }
+
+ private handlePermissionStateChange(state: PermissionState) {
+ const nextStatus =
+ state === "granted" || state === "prompt"
+ ? GeolocationStatus.PermissionGranted
+ : GeolocationStatus.PermissionDenied;
+
+ const nextState = this.state.withStatus(nextStatus);
+
+ if (this.state !== nextState) {
+ this.state = nextState;
+ super.dispatchEvent(new GeoplocationStatusChangeEvent(nextStatus));
+
+ if (nextStatus === GeolocationStatus.PermissionGranted) {
+ this.checkLocation();
+ this.watchId = this.setupWatchPosition();
+ }
+ }
+ }
+
+ private handlePostionChange(position: GeolocationPosition) {
+ const nextState = this.state.withPosition(position);
+
+ if (this.state !== nextState) {
+ this.state = nextState;
+ super.dispatchEvent(new GeoplocationPositionChangeEvent(position));
+ }
+ }
+
+ private handlePositionError(error: GeolocationPositionError) {
+ const nextState = this.state.withError(error);
+
+ if (this.state !== nextState) {
+ this.state = nextState;
+ super.dispatchEvent(new GeoplocationPositionErrorEvent(error));
+ }
+ }
+}
diff --git a/src/lanco-incidents-app/src/utils/distance-utils.ts b/src/lanco-incidents-app/src/utils/distance-utils.ts
index dbdd27b..42ae7df 100644
--- a/src/lanco-incidents-app/src/utils/distance-utils.ts
+++ b/src/lanco-incidents-app/src/utils/distance-utils.ts
@@ -3,20 +3,20 @@ import { getDistance } from "geolib";
type LatLng = { lat: number; lng: number };
const distanceBetween = (
- from: LatLng,
- to: LatLng,
- measurement: "meters" | "miles" = "miles",
+ from: LatLng,
+ to: LatLng,
+ measurement: "meters" | "miles" = "miles",
): number | undefined => {
- const measurementMultiplier = measurement === "miles" ? 0.00062137 : 1;
+ const measurementMultiplier = measurement === "miles" ? 0.00062137 : 1;
- const distance =
- from != null && to != null
- ? getDistance(to, from) * measurementMultiplier
- : undefined;
+ const distance =
+ from != null && to != null
+ ? getDistance(to, from) * measurementMultiplier
+ : undefined;
- return distance;
+ return distance;
};
export const DistanceUtils = {
- distanceBetween,
+ distanceBetween,
};
diff --git a/src/lanco-incidents-app/src/utils/typed-event-target.test.ts b/src/lanco-incidents-app/src/utils/typed-event-target.test.ts
new file mode 100644
index 0000000..1b5aa07
--- /dev/null
+++ b/src/lanco-incidents-app/src/utils/typed-event-target.test.ts
@@ -0,0 +1,82 @@
+import { TypedEventTarget } from "utils/typed-event-target";
+import { describe, it, expect, vi } from "vitest";
+
+describe("TypedEventTarget", () => {
+ it("is an EventTarget", () => {
+ // Arrange
+ class TestClass extends TypedEventTarget<{
+ "event-one": Event;
+ }>() {}
+
+ // Act
+ const result = new TestClass();
+
+ // Assert
+ expect(result instanceof EventTarget).toBeTruthy();
+ });
+
+ describe("dispatchEvent()", () => {
+ describe("when EventsMap has Custom events", () => {
+ it("it accepts custom events", () => {
+ // Arrange
+ class TestEvent extends Event {}
+ class TestClass extends TypedEventTarget<{
+ "event-one": TestEvent;
+ "event-two": TestEvent;
+ }>() {}
+ const testClass = new TestClass();
+
+ // Act
+ const result = testClass.dispatchEvent(
+ new TestEvent("event-one"),
+ );
+
+ // Assert
+ expect(result).toBeTruthy();
+ });
+ });
+ });
+
+ describe("addEventListener()", () => {
+ describe("when dispatchEvent", () => {
+ it("it calls eventListener", () => {
+ // Arrange
+ const eventOne = "event-one" as const;
+ class TestClass extends TypedEventTarget<{
+ [eventOne]: Event;
+ }>() {}
+ const eventListener = vi.fn();
+
+ const testClass = new TestClass();
+ testClass.addEventListener(eventOne, eventListener);
+
+ // Act
+ testClass.dispatchEvent(new Event(eventOne));
+
+ // Assert
+ expect(eventListener).toBeCalled();
+ });
+ });
+
+ describe("when dispatchEvent of unknown event", () => {
+ it("it does not call eventListener", () => {
+ // Arrange
+ const eventOne = "event-one" as const;
+ const eventTwo = "event-two" as const;
+ class TestClass extends TypedEventTarget<{
+ [eventOne]: Event;
+ }>() {}
+ const eventListener = vi.fn();
+
+ const testClass = new TestClass();
+ testClass.addEventListener(eventOne, eventListener);
+
+ // Act
+ testClass.dispatchEvent(new Event(eventTwo));
+
+ // Assert
+ expect(eventListener).not.toBeCalled();
+ });
+ });
+ });
+});
diff --git a/src/lanco-incidents-app/src/utils/typed-event-target.ts b/src/lanco-incidents-app/src/utils/typed-event-target.ts
new file mode 100644
index 0000000..6b1027e
--- /dev/null
+++ b/src/lanco-incidents-app/src/utils/typed-event-target.ts
@@ -0,0 +1,37 @@
+interface TypedEventListener {
+ (evt: TEvent): void;
+}
+
+interface TypedEventListenerObject {
+ handleEvent(object: TEvent): void;
+}
+
+type TypedEventListenerOrEventListenerObject =
+ | TypedEventListener
+ | TypedEventListenerObject;
+
+type EventsMap = Record;
+
+type StringKeyOf = keyof T extends string ? keyof T : never;
+
+interface TypedEventTargetInterface {
+ dispatchEvent(evt: TEventsMap[StringKeyOf]): boolean;
+ addEventListener>(
+ type: TType,
+ listener: TypedEventListenerOrEventListenerObject,
+ options?: AddEventListenerOptions | boolean,
+ ): void;
+
+ removeEventListener>(
+ type: TType,
+ listener: TypedEventListenerOrEventListenerObject,
+ options?: AddEventListenerOptions | boolean,
+ ): void;
+}
+
+export function TypedEventTarget() {
+ return EventTarget as unknown as {
+ new (): TypedEventTargetInterface;
+ prototype: TypedEventTargetInterface;
+ };
+}
diff --git a/src/lanco-incidents-app/src/utils/typed-event.test.ts b/src/lanco-incidents-app/src/utils/typed-event.test.ts
new file mode 100644
index 0000000..a2e9a48
--- /dev/null
+++ b/src/lanco-incidents-app/src/utils/typed-event.test.ts
@@ -0,0 +1,15 @@
+import { TypedEvent } from "utils/typed-event";
+import { describe, it, expect } from "vitest";
+
+describe("TypedEvent", () => {
+ it("is an Event", () => {
+ // Arrange
+ class TestEvent extends TypedEvent("event") {}
+
+ // Act
+ const event = new TestEvent("test");
+
+ // Assert
+ expect(event instanceof Event).toBeTruthy();
+ });
+});
diff --git a/src/lanco-incidents-app/src/utils/typed-event.ts b/src/lanco-incidents-app/src/utils/typed-event.ts
new file mode 100644
index 0000000..2d6af0c
--- /dev/null
+++ b/src/lanco-incidents-app/src/utils/typed-event.ts
@@ -0,0 +1,7 @@
+export function TypedEvent(type: string) {
+ return class extends Event {
+ constructor(public data: TData) {
+ super(type);
+ }
+ };
+}
diff --git a/src/lanco-incidents-func/Services/FeedService.cs b/src/lanco-incidents-func/Services/FeedService.cs
index 20c1426..b433e8d 100644
--- a/src/lanco-incidents-func/Services/FeedService.cs
+++ b/src/lanco-incidents-func/Services/FeedService.cs
@@ -3,16 +3,22 @@
using System.Threading.Tasks;
using LancoIncidentsFunc.Interfaces;
using LancoIncidentsFunc.Models;
+using Microsoft.Extensions.Logging;
namespace LancoIncidentsFunc.Services;
public class FeedService : IFeedService
{
private readonly IEnumerable _incidentProviders;
+ private readonly ILogger _logger;
- public FeedService(IEnumerable incidentProviders)
+ public FeedService(
+ IEnumerable incidentProviders,
+ ILogger logger
+ )
{
_incidentProviders = incidentProviders;
+ _logger = logger;
}
public async Task GetIncidentAsync(GlobalId globalId)
@@ -24,7 +30,27 @@ public async Task GetIncidentAsync(GlobalId globalId)
public Task> GetIncidentsAsync()
{
- return Task.WhenAll(_incidentProviders.Select((i) => i.GetIncidentsAsync()))
+ return Task.WhenAll(
+ _incidentProviders.Select(
+ async (i) =>
+ {
+ try
+ {
+ return await i.GetIncidentsAsync();
+ }
+ catch (System.Exception ex)
+ {
+ _logger.LogError(
+ ex,
+ $"Exception occured when calling GetIncidentsAsync",
+ $"Provider: {i.Key}"
+ );
+ }
+
+ return Enumerable.Empty();
+ }
+ )
+ )
.ContinueWith(t => t.Result.SelectMany(i => i));
}
}
diff --git a/src/lanco-incidents-func/lanco-incidents-func.csproj b/src/lanco-incidents-func/lanco-incidents-func.csproj
index 8f6096b..25b5c39 100644
--- a/src/lanco-incidents-func/lanco-incidents-func.csproj
+++ b/src/lanco-incidents-func/lanco-incidents-func.csproj
@@ -23,7 +23,7 @@
-
+
diff --git a/src/lanco-incidents-func/package.json b/src/lanco-incidents-func/package.json
index 6d12003..f594e60 100644
--- a/src/lanco-incidents-func/package.json
+++ b/src/lanco-incidents-func/package.json
@@ -4,16 +4,19 @@
"azurite": "docker run -p 10000:10000 -p 10001:10001 -p 10002:10002 --rm mcr.microsoft.com/azure-storage/azurite",
"func": "func start --csharp",
"dev": "concurrently --names \"Azurite,Functions\" -c \"bgBlue.bold,bgMagenta.bold\" \"pnpm run azurite\" \"pnpm run func\"",
+ "serve": "pnpm dev",
"build": "dotnet build -c Release",
"restore": "dotnet clean && dotnet restore"
},
"lint-staged": {
- "*.{cs}": ["dotnet csharpier"]
+ "*.{cs}": [
+ "dotnet csharpier"
+ ]
},
"volta": {
"extends": "../../package.json"
},
"devDependencies": {
- "concurrently": "^7.5.0"
+ "concurrently": "^7.6.0"
}
}
\ No newline at end of file
diff --git a/turbo.json b/turbo.json
index 91365b6..d4664ca 100644
--- a/turbo.json
+++ b/turbo.json
@@ -9,6 +9,14 @@
"dist/**"
]
},
+ "serve": {
+ "dependsOn": [
+ "^build", "build"
+ ],
+ "outputs": [
+ "dist/**"
+ ]
+ },
"test": {
"dependsOn": [
"build"