@geops/rvf-mobility-web-component 0.1.115 → 0.1.117

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/jest-setup.js CHANGED
@@ -3,3 +3,11 @@ import "jest-canvas-mock";
3
3
  global.URL.createObjectURL = jest.fn(() => {
4
4
  return "fooblob";
5
5
  });
6
+
7
+ class TextEncoder {
8
+ constructor() {}
9
+ decode() {}
10
+ encode() {}
11
+ }
12
+
13
+ global.TextDecoder = TextEncoder;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@geops/rvf-mobility-web-component",
3
3
  "license": "UNLICENSED",
4
4
  "description": "Web components for rvf in the domains of mobility and logistics.",
5
- "version": "0.1.115",
5
+ "version": "0.1.117",
6
6
  "homepage": "https://rvf-mobility-web-component.vercel.app/",
7
7
  "type": "module",
8
8
  "main": "index.js",
@@ -12,32 +12,32 @@
12
12
  "jspdf": "^4.1.0",
13
13
  "lodash.debounce": "^4.0.8",
14
14
  "maplibre-gl": "5.12.0",
15
- "mobility-toolbox-js": "3.6.4",
16
- "ol": "^10.7.0",
17
- "preact": "^10.28.3",
15
+ "mobility-toolbox-js": "3.6.7",
16
+ "ol": "^10.8.0",
17
+ "preact": "^10.28.4",
18
18
  "preact-custom-element": "^4.6.0",
19
19
  "react": "npm:@preact/compat@^18.3.1",
20
20
  "react-dom": "npm:@preact/compat@^18.3.1",
21
21
  "react-icons": "^5.5.0",
22
- "react-spatial": "^2.0.1",
22
+ "react-spatial": "^2.0.3",
23
23
  "rosetta": "^1.1.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@commitlint/cli": "^20.4.1",
27
- "@commitlint/config-conventional": "^20.4.1",
28
- "@eslint/js": "^9.39.2",
29
- "@geops/eslint-config-react": "^1.6.0-beta.1",
30
- "@tailwindcss/cli": "^4.1.18",
26
+ "@commitlint/cli": "^20.4.2",
27
+ "@commitlint/config-conventional": "^20.4.2",
28
+ "@eslint/js": "^9",
29
+ "@geops/eslint-config-react": "^1.6.0",
30
+ "@tailwindcss/cli": "^4.2.1",
31
31
  "@tailwindcss/container-queries": "^0.1.1",
32
32
  "@testing-library/preact": "^3.2.4",
33
33
  "@types/geojson": "^7946.0.16",
34
34
  "@types/jest": "^30.0.0",
35
- "@types/lodash": "^4.17.23",
35
+ "@types/lodash": "^4.17.24",
36
36
  "@types/preact-custom-element": "^4.0.4",
37
37
  "concurrently": "^9.2.1",
38
- "esbuild": "^0.27.2",
38
+ "esbuild": "^0.27.3",
39
39
  "esbuild-sass-plugin": "^3.6.0",
40
- "eslint": "^9.39.2",
40
+ "eslint": "^9",
41
41
  "eslint-plugin-tailwindcss": "^4.0.0-beta.0",
42
42
  "fixpack": "^4.0.0",
43
43
  "generact": "^0.4.0",
@@ -47,15 +47,15 @@
47
47
  "jest-environment-jsdom": "^30.2.0",
48
48
  "jest-preset-preact": "^4.1.1",
49
49
  "next": "15.5.7",
50
- "preact-render-to-string": "^6.6.5",
50
+ "preact-render-to-string": "^6.6.6",
51
51
  "prettier": "^3.8.1",
52
52
  "prettier-plugin-tailwindcss": "^0.7.2",
53
53
  "standard-version": "^9.5.0",
54
- "tailwind-merge": "^3.4.0",
55
- "tailwindcss": "^4.1.18",
54
+ "tailwind-merge": "^3.5.0",
55
+ "tailwindcss": "^4.2.1",
56
56
  "ts-jest": "^29.4.6",
57
57
  "typescript": "^5.9.3",
58
- "typescript-eslint": "^8.54.0"
58
+ "typescript-eslint": "^8.56.1"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "yarn build:css && yarn build:js && cp index*.js* doc/public/",
@@ -24,6 +24,11 @@ function FeaturesInfosListener() {
24
24
  } = useMapContext();
25
25
 
26
26
  useEffect(() => {
27
+ // We want to call this use effect only when a hover happened
28
+ // and a query has been done not when the component is mounted
29
+ if (!featuresInfosHovered?.length) {
30
+ return;
31
+ }
27
32
  const [realtimeFeature] =
28
33
  featuresInfosHovered?.find((info) => {
29
34
  return info.layer === realtimeLayer;
@@ -34,7 +39,7 @@ function FeaturesInfosListener() {
34
39
  return info.layer === notificationsLayer;
35
40
  })?.features || [];
36
41
 
37
- // We prioritize only symbol notifications, becaus ewe want to be able to click on trians on lines
42
+ // We prioritize only symbol notifications, because we want to be able to click on trains on lines
38
43
  const isSymbolNotification =
39
44
  notificationFeature?.getGeometry()?.getType() === "Point";
40
45
 
@@ -50,6 +55,15 @@ function FeaturesInfosListener() {
50
55
  }, [featuresInfosHovered, notificationsLayer, realtimeLayer]);
51
56
 
52
57
  useEffect(() => {
58
+ // We want to call this use effect only when a click happened
59
+ // and a query has been done not when the component is mounted
60
+ if (!featuresInfos?.length) {
61
+ // Reinitialize selected features
62
+ setSelectedFeature(null);
63
+ setSelectedFeatures([]);
64
+ return;
65
+ }
66
+
53
67
  const [realtimeFeature] =
54
68
  featuresInfos?.find((info) => {
55
69
  return info.layer === realtimeLayer;
@@ -84,7 +98,7 @@ function FeaturesInfosListener() {
84
98
  return info.features;
85
99
  }) || [];
86
100
 
87
- // We prioritize only symbol notifications, becaus ewe want to be able to click on trians on lines
101
+ // We prioritize only symbol notifications, because we want to be able to click on trains on lines
88
102
  const isSymbolNotification =
89
103
  notificationFeature?.getGeometry()?.getType() === "Point";
90
104
 
@@ -345,10 +345,10 @@ function LayoutState() {
345
345
 
346
346
  useEffect(() => {
347
347
  setIsOverlayOpen(
348
- (hasDetails && !!selectedFeature) ||
349
- (hasPrint && isExportMenuOpen) ||
348
+ (hasPrint && isExportMenuOpen) ||
350
349
  (hasLayerTree && isLayerTreeOpen) ||
351
350
  (hasShare && isShareMenuOpen) ||
351
+ (hasDetails && !!selectedFeature) || // For custom layers details.
352
352
  (hasDetails && hasRealtime && !!trainId) ||
353
353
  (hasDetails && tenant && !!stationId) ||
354
354
  (hasDetails && hasLnp && !!linesIds) ||
@@ -11,6 +11,7 @@ import Link from "../ui/Link";
11
11
  import useI18n from "../utils/hooks/useI18n";
12
12
  import useMapContext from "../utils/hooks/useMapContext";
13
13
  import useMocoSituation from "../utils/hooks/useMocoSituation";
14
+ import mergePublications from "../utils/mergePublications";
14
15
 
15
16
  import type {
16
17
  MultilingualTextualContentType,
@@ -88,6 +89,9 @@ function NotificationDetails({
88
89
  });
89
90
  }) || [];
90
91
 
92
+ // Merge similar publications
93
+ publicationsToDisplay = mergePublications(publicationsToDisplay);
94
+
91
95
  // Display the current and next affected time intervals not the one in the past
92
96
  timeIntervalsToDisplay =
93
97
  (situationParsed?.affectedTimeIntervals || []).filter(
@@ -39,25 +39,21 @@ function Overlay({
39
39
  className)
40
40
  }
41
41
  >
42
- {hasChildren && (
43
- <>
44
- <ScrollableHandler
45
- className="absolute inset-0 flex h-[65px] touch-none justify-center @lg/main:hidden"
46
- style={{ width: "100%" }}
47
- {...ScrollableHandlerProps}
48
- >
49
- <div
50
- className="m-2 mr-[-60px] bg-gray-300"
51
- style={{
52
- borderRadius: 2,
53
- height: 4,
54
- width: 32,
55
- }}
56
- />
57
- </ScrollableHandler>
58
- {children}
59
- </>
60
- )}
42
+ <ScrollableHandler
43
+ className="absolute inset-0 flex h-[65px] touch-none justify-center @lg/main:hidden"
44
+ style={{ width: "100%" }}
45
+ {...ScrollableHandlerProps}
46
+ >
47
+ <div
48
+ className="m-2 mr-[-60px] bg-gray-300"
49
+ style={{
50
+ borderRadius: 2,
51
+ height: 4,
52
+ width: 32,
53
+ }}
54
+ />
55
+ </ScrollableHandler>
56
+ {children}
61
57
  </div>
62
58
  );
63
59
  }
@@ -94,13 +94,14 @@ function RealtimeLayer(props: Partial<RealtimeLayerOptions>) {
94
94
  }, [apikey, realtimeurl, realtimebboxparameters, mots, tenant, props]);
95
95
 
96
96
  useEffect(() => {
97
+ let key;
97
98
  if (!map || !layer) {
98
99
  return;
99
100
  }
100
101
  if (map.getView()?.getCenter()) {
101
102
  map.addLayer(layer);
102
103
  } else {
103
- map.once("moveend", () => {
104
+ key = map.once("moveend", () => {
104
105
  map.addLayer(layer);
105
106
  });
106
107
  }
@@ -108,6 +109,9 @@ function RealtimeLayer(props: Partial<RealtimeLayerOptions>) {
108
109
  setRealtimeLayer(layer);
109
110
 
110
111
  return () => {
112
+ if (key) {
113
+ unByKey(key);
114
+ }
111
115
  map.removeLayer(layer);
112
116
  setRealtimeLayer(null);
113
117
  };
@@ -0,0 +1,30 @@
1
+ import { memo } from "preact/compat";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ import Tracking from "../icons/Tracking";
5
+ import IconButton from "../ui/IconButton";
6
+ import useMapContext from "../utils/hooks/useMapContext";
7
+
8
+ import type { IconButtonProps } from "../ui/IconButton/IconButton";
9
+
10
+ function RouteFollowingButton({
11
+ children,
12
+ className,
13
+ ...props
14
+ }: IconButtonProps) {
15
+ const { isFollowing, setIsFollowing } = useMapContext();
16
+ return (
17
+ <IconButton
18
+ className={twMerge(`${isFollowing ? "animate-pulse" : ""}`, className)}
19
+ onClick={() => {
20
+ setIsFollowing(!isFollowing);
21
+ }}
22
+ selected={isFollowing}
23
+ {...props}
24
+ >
25
+ {children || <Tracking />}
26
+ </IconButton>
27
+ );
28
+ }
29
+
30
+ export default memo(RouteFollowingButton);
@@ -0,0 +1 @@
1
+ export { default } from "./RouteFollowingButton";
@@ -1,5 +1,5 @@
1
1
  import { memo } from "preact/compat";
2
- import { useEffect, useRef } from "preact/hooks";
2
+ import { useRef } from "preact/hooks";
3
3
  import { twMerge } from "tailwind-merge";
4
4
 
5
5
  import RouteScheduleFooter from "../RouteScheduleFooter";
@@ -8,6 +8,7 @@ import RouteStop from "../RouteStop";
8
8
  import ShadowOverflow from "../ShadowOverflow";
9
9
  import useMapContext from "../utils/hooks/useMapContext";
10
10
  import useRealtimeStopSequences from "../utils/hooks/useRealtimeStopSequences";
11
+ import useScrollTo from "../utils/hooks/useScrollTo";
11
12
 
12
13
  import type { RealtimeStop } from "mobility-toolbox-js/types";
13
14
  import type { HTMLAttributes, PreactDOMAttributes } from "preact";
@@ -20,29 +21,9 @@ export type RouteScheduleProps = {
20
21
  function RouteSchedule({ className }: RouteScheduleProps) {
21
22
  const { trainId } = useMapContext();
22
23
  const stopSequences = useRealtimeStopSequences(trainId);
23
- const ref = useRef();
24
+ const ref = useRef<HTMLDivElement>();
24
25
 
25
- useEffect(() => {
26
- const interval = window.setInterval(() => {
27
- const elt = ref.current as HTMLDivElement;
28
- if (!elt) {
29
- return;
30
- }
31
- const nextStation = elt.querySelector("[data-station-passed=false]");
32
- if (nextStation) {
33
- // We use scrollTo avoid scrolling the entire window.
34
- (nextStation.parentNode.parentNode as Element).scrollTo({
35
- behavior: "smooth",
36
- top: (nextStation as HTMLElement).offsetTop || 0,
37
- });
38
- }
39
- clearInterval(interval);
40
- }, 300);
41
- return () => {
42
- clearTimeout(interval);
43
- };
44
- // Scroll automatically when a new scroll infos is set.
45
- }, [stopSequences]);
26
+ useScrollTo(ref, "[data-station-passed=false]", [stopSequences]);
46
27
 
47
28
  if (!stopSequences?.[0]) {
48
29
  return null;
@@ -51,8 +32,8 @@ function RouteSchedule({ className }: RouteScheduleProps) {
51
32
  return (
52
33
  <>
53
34
  <RouteScheduleHeader stopSequence={stopSequence} />
54
- <ShadowOverflow>
55
- <div className={twMerge("text-base", className)} ref={ref}>
35
+ <ShadowOverflow ref={ref}>
36
+ <div className={twMerge("text-base", className)}>
56
37
  {stopSequence.stations.map((stop: RealtimeStop, index: number) => {
57
38
  const { arrivalTime, departureTime, stationId, stationName } = stop;
58
39
  return (
@@ -1,10 +1,8 @@
1
1
  import { memo } from "preact/compat";
2
2
 
3
- import Tracking from "../icons/Tracking";
3
+ import RouteFollowingButton from "../RouteFollowingButton";
4
4
  import RouteIcon from "../RouteIcon";
5
5
  import RouteInfos from "../RouteInfos";
6
- import IconButton from "../ui/IconButton";
7
- import useMapContext from "../utils/hooks/useMapContext";
8
6
 
9
7
  import type { RealtimeStopSequence } from "mobility-toolbox-js/types";
10
8
 
@@ -13,20 +11,11 @@ function RouteScheduleHeader({
13
11
  }: {
14
12
  stopSequence: RealtimeStopSequence;
15
13
  }) {
16
- const { isFollowing, setIsFollowing } = useMapContext();
17
14
  return (
18
15
  <div className="flex items-center gap-x-4 bg-slate-100 p-4 py-2">
19
16
  <RouteIcon stopSequence={stopSequence} />
20
17
  <RouteInfos className="flex grow flex-col" stopSequence={stopSequence} />
21
- <IconButton
22
- className={`${isFollowing ? "animate-pulse" : ""}`}
23
- onClick={() => {
24
- setIsFollowing(!isFollowing);
25
- }}
26
- selected={isFollowing}
27
- >
28
- <Tracking />
29
- </IconButton>
18
+ <RouteFollowingButton />
30
19
  </div>
31
20
  );
32
21
  }
@@ -1,5 +1,6 @@
1
1
  import { memo } from "preact/compat";
2
2
  import { useEffect, useMemo, useState } from "preact/hooks";
3
+ import { twMerge } from "tailwind-merge";
3
4
 
4
5
  import DebugStop from "../DebugStop/DebugStop";
5
6
  import RouteStopDelay from "../RouteStopDelay";
@@ -30,6 +31,7 @@ export type RouteScheduleStopProps = {
30
31
 
31
32
  function RouteStop({
32
33
  children,
34
+ className,
33
35
  classNameGreyOut = "text-gray-600",
34
36
  index,
35
37
  invertColor = false,
@@ -74,7 +76,10 @@ function RouteStop({
74
76
  <RouteStopContext.Provider value={routeStopState}>
75
77
  <button
76
78
  // max-h-[58px] because the svg showing the progress is 58px height.
77
- className={`flex max-h-[58px] w-full scroll-mt-[50px] items-stretch rounded text-left hover:bg-slate-100 ${colorScheme}`}
79
+ className={twMerge(
80
+ `flex max-h-[58px] w-full scroll-mt-[50px] items-stretch rounded text-left hover:bg-slate-100 ${colorScheme}`,
81
+ className,
82
+ )}
78
83
  data-station-passed={status.isPassed} // Use for auto scroll
79
84
  onClick={() => {
80
85
  if (stop.coordinate) {
@@ -1,15 +1,23 @@
1
- import { memo } from "preact/compat";
1
+ import { forwardRef, memo } from "preact/compat";
2
2
  import { twMerge } from "tailwind-merge";
3
3
 
4
4
  import type { PreactDOMAttributes } from "preact";
5
+ import type { MutableRef } from "preact/hooks";
5
6
 
6
- function ShadowOverflow({
7
- children,
8
- className,
9
- ...props
10
- }: { className?: string } & PreactDOMAttributes) {
7
+ function ShadowOverflow(
8
+ {
9
+ children,
10
+ className,
11
+ ...props
12
+ }: { className?: string } & PreactDOMAttributes,
13
+ ref: MutableRef<HTMLDivElement>,
14
+ ) {
11
15
  return (
12
- <div {...props} className={twMerge("relative overflow-y-auto", className)}>
16
+ <div
17
+ {...props}
18
+ className={twMerge("relative overflow-y-auto", className)}
19
+ ref={ref}
20
+ >
13
21
  <div className="pointer-events-none sticky top-0 right-0 left-0 h-4 bg-gradient-to-t from-transparent to-white"></div>
14
22
  {children}
15
23
  <div className="pointer-events-none sticky right-0 bottom-[-1px] left-0 h-4 bg-gradient-to-b from-transparent to-white"></div>
@@ -17,4 +25,4 @@ function ShadowOverflow({
17
25
  );
18
26
  }
19
27
 
20
- export default memo(ShadowOverflow);
28
+ export default memo(forwardRef(ShadowOverflow));
@@ -13,6 +13,7 @@ import ArrowUp from "../icons/ArrowUp";
13
13
  import Warning from "../icons/Warning";
14
14
  import Link from "../ui/Link";
15
15
  import useI18n from "../utils/hooks/useI18n";
16
+ import mergePublications from "../utils/mergePublications";
16
17
 
17
18
  import type { MultilingualTextualContentType } from "mobility-toolbox-js/types";
18
19
 
@@ -99,6 +100,8 @@ function SituationDetails({
99
100
  });
100
101
  }) || [];
101
102
 
103
+ publicationsToDisplay = mergePublications(publicationsToDisplay);
104
+
102
105
  // Display the current and next affected time intervals not the one in the past
103
106
  timeIntervalsToDisplay =
104
107
  (situation?.affectedTimeIntervals || []).filter(
@@ -112,7 +115,7 @@ function SituationDetails({
112
115
 
113
116
  // Display the reasons
114
117
  reasonsToDisplay = (situation?.reasons || []).map(({ name }) => {
115
- return name;
118
+ return name && t(name.replace(".", ""));
116
119
  });
117
120
 
118
121
  return (
@@ -291,7 +291,7 @@ function StopsSearch({
291
291
  resultsContainerClassName,
292
292
  )}
293
293
  >
294
- {results && results.length === 0 && (
294
+ {!!results?.length && (
295
295
  <div
296
296
  className={twMerge(
297
297
  "flex grow gap-3 border border-solid p-3 pt-2 text-zinc-400",
@@ -108,7 +108,7 @@ function useLnpLineInfo(text: string): LnpLineInfo {
108
108
 
109
109
  return Object.values(linesInfos).find((info) => {
110
110
  return ["id", "external_id", "short_name", "long_name"].find((key) => {
111
- return !!info[key] && info[key] === text;
111
+ return !!info[key] && info[key].toLowerCase() === text.toLowerCase();
112
112
  });
113
113
  });
114
114
  }
@@ -0,0 +1,36 @@
1
+ import { useLayoutEffect } from "react";
2
+
3
+ import type { MutableRef } from "react";
4
+
5
+ const defaultScrollOptions: ScrollIntoViewOptions = { behavior: "smooth" };
6
+
7
+ function useScrollTo(
8
+ ref: MutableRef<HTMLElement>,
9
+ targetSelector: string,
10
+ dependenciesArray: unknown[] = [],
11
+ scrollToOptions: ScrollIntoViewOptions = defaultScrollOptions,
12
+ ) {
13
+ useLayoutEffect(() => {
14
+ const elt = ref.current;
15
+ if (!elt) {
16
+ return;
17
+ }
18
+ const scrollToElt = elt.querySelector(targetSelector);
19
+
20
+ if (scrollToElt) {
21
+ try {
22
+ // We use scrollTo avoid scrolling the entire window.
23
+ elt.scrollTo({
24
+ ...scrollToOptions,
25
+ top: (scrollToElt as HTMLElement).offsetTop || 0,
26
+ });
27
+ } catch (err) {
28
+ // eslint-disable-next-line no-console
29
+ console.error("Error while scrolling to element", err);
30
+ }
31
+ }
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
33
+ }, [...dependenciesArray]);
34
+ }
35
+
36
+ export default useScrollTo;
@@ -0,0 +1,91 @@
1
+ import mergePublications from "./mergePublications";
2
+
3
+ import type { PublicationType } from "mobility-toolbox-js/types";
4
+
5
+ describe("mergePublications", () => {
6
+ it("should merge publications with same publication windows and textual contents", () => {
7
+ const publications = [
8
+ {
9
+ publicationLines: [
10
+ {
11
+ category: "DISRUPTION",
12
+ hasIcon: true,
13
+ lines: [
14
+ {
15
+ name: "510",
16
+ operatorRef: "SBG",
17
+ },
18
+ {
19
+ name: "510",
20
+ operatorRef: "SBG",
21
+ },
22
+ {
23
+ name: "510",
24
+ operatorRef: "SBG",
25
+ },
26
+ ],
27
+ mot: "BUS",
28
+ },
29
+ ],
30
+ publicationStops: [],
31
+ publicationWindows: [],
32
+ serviceCondition: "DIVERTED",
33
+ serviceConditionGroup: "CHANGES",
34
+ severity: "NORMAL",
35
+ severityGroup: "NORMAL",
36
+ textualContentLarge: null,
37
+ textualContentMedium: {
38
+ de: {
39
+ consequence: "",
40
+ description:
41
+ "Zwischen <b>Denzlingen Mattenbühl</b> und <b>Denzlingen Bahnhof</b> kommt es vom <b>26. Februar 2026 ab 08:00 Uhr bis auf Weiteres</b> zu <b>einer Umleitung und Haltausfällen</b> wegen einer Baumaßnahme. Betroffen <b>sind die Linien 510 und 512</b>. Es kommt zu folgenden Änderungen:<br>\n <b>Umleitung Linie 510</b>: Die Haltestellen Denzlingen Gewerbegebiet bis Denzlingen Mattenbühl werden nicht bedient. <br><br>Die Änderungen sind in der elektronischen Fahrplanauskunft EFA hinterlegt.",
42
+ durationText: "",
43
+ reason: "",
44
+ recommendation: "",
45
+ summary:
46
+ "Linien 510 und 512: Umleitung und Haltentfall wegen einer Baumaßnahme",
47
+ },
48
+ },
49
+ },
50
+ {
51
+ publicationLines: [
52
+ {
53
+ category: "DISRUPTION",
54
+ hasIcon: true,
55
+ lines: [
56
+ {
57
+ name: "512",
58
+ operatorRef: "SBG",
59
+ },
60
+ ],
61
+ mot: "BUS",
62
+ },
63
+ ],
64
+ publicationStops: [],
65
+ publicationWindows: [],
66
+ serviceCondition: "DIVERTED",
67
+ serviceConditionGroup: "CHANGES",
68
+ severity: "NORMAL",
69
+ severityGroup: "NORMAL",
70
+ textualContentLarge: null,
71
+ textualContentMedium: {
72
+ de: {
73
+ consequence: "",
74
+ description:
75
+ "Zwischen <b>Denzlingen Mattenbühl</b> und <b>Denzlingen Bahnhof</b> kommt es vom <b>26. Februar 2026 ab 08:00 Uhr bis auf Weiteres</b> zu <b>einer Umleitung und Haltausfällen</b> wegen einer Baumaßnahme. Betroffen <b>sind die Linien 510 und 512</b>. Es kommt zu folgenden Änderungen:<br>\n <b>Umleitung Linie 510</b>: Die Haltestellen Denzlingen Gewerbegebiet bis Denzlingen Mattenbühl werden nicht bedient. <br><br>Die Änderungen sind in der elektronischen Fahrplanauskunft EFA hinterlegt.",
76
+ durationText: "",
77
+ reason: "",
78
+ recommendation: "",
79
+ summary:
80
+ "Linien 510 und 512: Umleitung und Haltentfall wegen einer Baumaßnahme",
81
+ },
82
+ },
83
+ },
84
+ ] as PublicationType[];
85
+ const merged = mergePublications(publications);
86
+ expect(merged).toHaveLength(1);
87
+ expect(merged[0].publicationLines).toHaveLength(2);
88
+ expect(merged[0].publicationLines?.[0].lines[0].name).toBe("510");
89
+ expect(merged[0].publicationLines?.[1].lines[0].name).toBe("512");
90
+ });
91
+ });
@@ -0,0 +1,52 @@
1
+ import type { PublicationType } from "mobility-toolbox-js/types";
2
+
3
+ /**
4
+ * When imported from external source, some publication have the same publication
5
+ * windows and the same textualContents but only differs the lines or stations.
6
+ * In that case we merge them (lines and stops) to avoid duplicates in the display.
7
+ * TODO fixes this in backend oder import
8
+ */
9
+ const mergePublications = (
10
+ publications: PublicationType[] = [],
11
+ ): PublicationType[] => {
12
+ const mergedPublications: PublicationType[] = [];
13
+ try {
14
+ publications.forEach((publication) => {
15
+ const publicationsWindowsStr = JSON.stringify(
16
+ publication.publicationWindows,
17
+ );
18
+ const textualContentStr = JSON.stringify(publication.textualContents);
19
+
20
+ // Find a previous identical publication
21
+ const identicalPublication = mergedPublications.find((p) => {
22
+ return (
23
+ p.severity === publication.severity &&
24
+ p.serviceCondition === publication.serviceCondition &&
25
+ JSON.stringify(p.publicationWindows) === publicationsWindowsStr &&
26
+ JSON.stringify(p.textualContents) === textualContentStr
27
+ );
28
+ });
29
+
30
+ if (identicalPublication) {
31
+ // We move the lines and stops on the same publication to display them together and avoid duplicates
32
+ identicalPublication.publicationLines = [
33
+ ...(identicalPublication.publicationLines || []),
34
+ ...(publication.publicationLines || []),
35
+ ];
36
+ identicalPublication.publicationStops = [
37
+ ...(identicalPublication.publicationStops || []),
38
+ ...(publication.publicationStops || []),
39
+ ];
40
+ return false;
41
+ }
42
+ mergedPublications.push(publication);
43
+ });
44
+ } catch (error) {
45
+ // JSON parsing could failed, we log the error and return the non merged
46
+ // publications to avoid breaking the display
47
+ console.error("Error merging publications", error);
48
+ return publications;
49
+ }
50
+ return mergedPublications;
51
+ };
52
+ export default mergePublications;