Skip to content

Commit d68181a

Browse files
committed
fix(road-conditions): localize incident popup values
1 parent 3ff4a48 commit d68181a

6 files changed

Lines changed: 227 additions & 9 deletions

File tree

apps/web/src/components/map/overlay/popupCard.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,17 @@ describe("buildPopupCard", () => {
6565
expect(html).toContain("#cc0033"); // high severity color, inlined
6666
});
6767

68+
it("uses a configured localized label for the severity badge", () => {
69+
const html = buildPopupCard(
70+
{ titleField: "h", severityField: "severity", severityLabelField: "severityText", rows: [] },
71+
{ h: "T", severity: "medium", severityText: "Mittel" },
72+
);
73+
74+
expect(html).toContain("Mittel");
75+
expect(html).not.toContain(">Medium<");
76+
expect(html).toContain("#ff9933"); // medium severity color still uses the raw value
77+
});
78+
6879
it("omits the severity badge when severity is unknown or empty", () => {
6980
for (const severity of ["unknown", "Unknown", "", null]) {
7081
const html = buildPopupCard(

apps/web/src/components/map/overlay/popupCard.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export interface PopupCardSpec {
2323
titleField: string;
2424
/** Field whose value (low|medium|high|critical|unknown) → colored severity badge. */
2525
severityField?: string;
26+
/** Optional field containing the localized text for the severity badge. */
27+
severityLabelField?: string;
2628
/** Field holding a source credit — a string or `{ provider, license }` → muted footer. */
2729
attributionField?: string;
2830
rows?: PopupCardRow[];
@@ -119,7 +121,12 @@ function renderCardBody(
119121
// "UNKNOWN" pill is just noise. The marker still carries the unknown color.
120122
if (sevRaw != null && sevRaw !== "" && String(sevRaw).toLowerCase() !== "unknown") {
121123
const style = SEVERITY_STYLE[String(sevRaw).toLowerCase()] ?? SEVERITY_STYLE.unknown;
122-
badge = `<span class="omx-overlay-popup__badge" style="background:${style.bg};color:${style.fg}">${escapeHtml(humanize(String(sevRaw)))}</span>`;
124+
const labelRaw = spec.severityLabelField ? properties[spec.severityLabelField] : undefined;
125+
const label =
126+
labelRaw == null || String(labelRaw).trim() === ""
127+
? humanize(String(sevRaw))
128+
: String(labelRaw);
129+
badge = `<span class="omx-overlay-popup__badge" style="background:${style.bg};color:${style.fg}">${escapeHtml(label)}</span>`;
123130
}
124131
}
125132
const header = `<div class="omx-overlay-popup__header"><span class="omx-overlay-popup__title">${title}</span></div>`;

integrations/road-conditions/__tests__/popup.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,23 @@ const translate = (key: string, values?: Record<string, string | number>) => {
5050
return key;
5151
};
5252

53+
const germanMessages: Record<string, string> = {
54+
"panel.type": "Typ",
55+
"panel.roadState": "Status",
56+
"panel.roads": "Betroffene Straßen",
57+
"panel.validity": "Aktiv",
58+
"panel.startsAt": "Beginnt am",
59+
"panel.delay": "Verzögerung",
60+
"panel.description": "Details",
61+
"type.roadworks": "Baustelle",
62+
"sev.medium": "Mittel",
63+
"roadState.some_lanes_closed": "Einige Fahrspuren gesperrt",
64+
"schedule.from": "ab",
65+
"schedule.days.MO": "Mo",
66+
"schedule.days.WE": "Mi",
67+
};
68+
const germanTranslate = (key: string) => germanMessages[key] ?? key;
69+
5370
function hit(
5471
properties: Record<string, unknown>,
5572
geometry: MapGeoJSONFeature["geometry"],
@@ -69,6 +86,73 @@ function build(hits: MapGeoJSONFeature[]) {
6986
}
7087

7188
describe("buildRoadConditionPopupHtml", () => {
89+
it("localizes severity, type, road state, and recurring schedule text", () => {
90+
const displayId = "group:german-roadworks";
91+
const event: RoadConditionEvent = {
92+
...events[0],
93+
id: "source:german",
94+
groupId: "german-roadworks",
95+
severity: "medium",
96+
roadState: "some_lanes_closed",
97+
validFrom: "2026-08-06T00:00:00Z",
98+
validTo: "2026-08-14T00:00:00Z",
99+
schedule: [
100+
{
101+
scheduleTimezone: "Europe/Berlin",
102+
byDay: ["MO", "WE"],
103+
startTime: "22:00:00",
104+
endTime: "00:00:00",
105+
startDate: "2026-08-06",
106+
endDate: "2026-08-14",
107+
},
108+
],
109+
};
110+
const result = buildRoadConditionPopupHtml({
111+
hits: [hit({ _displayId: displayId, _sev: 2 }, { type: "Point", coordinates: [6.78, 51.2] })],
112+
fallbackCoordinates: [6.77, 51.2],
113+
eventsByDisplayId: new Map([[displayId, [event]]]),
114+
formatDateTime: (value) => `datetime:${value}`,
115+
formatDate: (value) => `date:${value}`,
116+
translate: germanTranslate,
117+
});
118+
119+
expect(result.html).toContain("Mittel");
120+
expect(result.html).toContain("Baustelle");
121+
expect(result.html).toContain("Einige Fahrspuren gesperrt");
122+
expect(result.html).toContain("Mo, Mi, 22:00–00:00, date:2026-08-06 – date:2026-08-14");
123+
expect(result.html).not.toContain("some_lanes_closed");
124+
expect(result.html).not.toContain("MO, WE");
125+
expect(result.html).not.toContain("from 22:00");
126+
});
127+
128+
it("uses a localized label for a future incident start time", () => {
129+
const futureDisplayId = "group:future-roadworks";
130+
const futureEvent: RoadConditionEvent = {
131+
...events[0],
132+
id: "source:future",
133+
groupId: "future-roadworks",
134+
validFrom: "2026-08-14T00:00:00Z",
135+
};
136+
const result = buildRoadConditionPopupHtml({
137+
hits: [
138+
hit({ _displayId: futureDisplayId, _sev: 1 }, { type: "Point", coordinates: [6.78, 51.2] }),
139+
],
140+
fallbackCoordinates: [6.77, 51.2],
141+
eventsByDisplayId: new Map([[futureDisplayId, [futureEvent]]]),
142+
formatDateTime: (value) => String(value),
143+
formatDate: (value) => String(value),
144+
translate: (key, values) => {
145+
if (key === "panel.startsAt") return "Starts at";
146+
return translate(key, values);
147+
},
148+
});
149+
150+
expect(result.html).toContain(
151+
'class="omx-overlay-popup__label">Starts at</span><span class="omx-overlay-popup__value">2026-08-14T00:00:00Z',
152+
);
153+
expect(result.html).not.toContain('class="omx-overlay-popup__label">startsAt</span>');
154+
});
155+
72156
it("produces identical grouped popup content for a marker hit and a line hit", () => {
73157
const marker = build([
74158
hit(

integrations/road-conditions/popup.tsx

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@ export const ROAD_CONDITION_SEVERITY_RANK: Record<string, number> = {
1414
const POPUP_SPEC: PopupCardSpec = {
1515
titleField: "headline",
1616
severityField: "severity",
17+
severityLabelField: "severityText",
1718
attributionField: "attribution",
1819
rows: [
19-
{ field: "type", labelKey: "panel.type", format: "label", variant: "chip" },
20-
{ field: "roadState", labelKey: "panel.roadState", format: "label", variant: "chip" },
20+
{ field: "typeText", labelKey: "panel.type", variant: "chip" },
21+
{ field: "roadStateText", labelKey: "panel.roadState", variant: "chip" },
2122
{ field: "roads", labelKey: "panel.roads", variant: "row" },
2223
{ field: "recordId", labelKey: "panel.sourceRecord", variant: "row" },
2324
{ field: "validity", labelKey: "panel.validity", variant: "row" },
24-
{ field: "startsAt", variant: "row" },
25+
{ field: "startsAt", labelKey: "panel.startsAt", variant: "row" },
2526
{ field: "delayText", labelKey: "panel.delay", variant: "row" },
2627
{ field: "description", labelKey: "panel.description", variant: "block" },
2728
],
@@ -30,9 +31,10 @@ const POPUP_SPEC: PopupCardSpec = {
3031
const SOURCE_DETAIL_SPEC: PopupCardSpec = {
3132
titleField: "headline",
3233
severityField: "severity",
34+
severityLabelField: "severityText",
3335
rows: [
34-
{ field: "type", labelKey: "panel.type", format: "label", variant: "chip" },
35-
{ field: "roadState", labelKey: "panel.roadState", format: "label", variant: "chip" },
36+
{ field: "typeText", labelKey: "panel.type", variant: "chip" },
37+
{ field: "roadStateText", labelKey: "panel.roadState", variant: "chip" },
3638
{ field: "roads", labelKey: "panel.roads", variant: "row" },
3739
{ field: "recordId", labelKey: "panel.sourceRecord", variant: "row" },
3840
{ field: "validity", labelKey: "panel.validity", variant: "row" },
@@ -73,19 +75,26 @@ function formatValidity(
7375
to: unknown,
7476
fmtDateTime: (value: string | number | Date) => string,
7577
fmtDate: (value: string | number | Date) => string,
78+
translate: RoadConditionTranslate,
7679
): string {
7780
if (typeof scheduleJson === "string" && scheduleJson) {
7881
try {
7982
const windows = JSON.parse(scheduleJson) as ScheduleEntry[];
8083
const hhmm = (time?: string) => (time ? time.slice(0, 5) : "");
8184
const parts = windows
8285
.map((window) => {
83-
const days = window.byDay && window.byDay.length > 0 ? window.byDay.join(", ") : "";
86+
const days =
87+
window.byDay && window.byDay.length > 0
88+
? window.byDay
89+
.map((day) => scheduleDayLabel(day, translate))
90+
.filter(Boolean)
91+
.join(", ")
92+
: "";
8493
const band =
8594
window.startTime && window.endTime
8695
? `${hhmm(window.startTime)}${hhmm(window.endTime)}`
8796
: window.startTime
88-
? `from ${hhmm(window.startTime)}`
97+
? `${translate("schedule.from")} ${hhmm(window.startTime)}`
8998
: "";
9099
const range =
91100
window.startDate && window.endDate
@@ -107,6 +116,53 @@ function formatValidity(
107116
return `${f || "…"}${t || "…"}`;
108117
}
109118

119+
const ROAD_CONDITION_TYPES = new Set<RoadConditionType>([
120+
"accident",
121+
"roadworks",
122+
"road_closure",
123+
"lane_closure",
124+
"hazard",
125+
"congestion",
126+
"weather",
127+
"event",
128+
"restriction",
129+
"other",
130+
]);
131+
132+
const ROAD_CONDITION_STATES = new Set([
133+
"open",
134+
"closed",
135+
"some_lanes_closed",
136+
"single_lane_alternating",
137+
]);
138+
139+
const ROAD_CONDITION_SEVERITIES = new Set(Object.keys(ROAD_CONDITION_SEVERITY_RANK));
140+
const SCHEDULE_WEEKDAY_CODES = new Set(["MO", "TU", "WE", "TH", "FR", "SA", "SU"]);
141+
142+
function humanizeToken(raw: string): string {
143+
const value = raw.replace(/[_-]+/g, " ").trim();
144+
return value.length === 0 ? value : value.charAt(0).toUpperCase() + value.slice(1);
145+
}
146+
147+
function localizedTokenList(
148+
raw: unknown,
149+
prefix: string,
150+
known: ReadonlySet<string>,
151+
translate: RoadConditionTranslate,
152+
): string | undefined {
153+
if (typeof raw !== "string") return undefined;
154+
const tokens = distinctNonEmpty(raw.split(","));
155+
if (tokens.length === 0) return undefined;
156+
return tokens
157+
.map((token) => (known.has(token) ? translate(`${prefix}.${token}`) : humanizeToken(token)))
158+
.join(", ");
159+
}
160+
161+
function scheduleDayLabel(day: string, translate: RoadConditionTranslate): string {
162+
const code = day.trim().toUpperCase();
163+
return SCHEDULE_WEEKDAY_CODES.has(code) ? translate(`schedule.days.${code}`) : day.trim();
164+
}
165+
110166
function attributionString(raw: unknown): string {
111167
if (typeof raw === "string") return raw;
112168
if (raw && typeof raw === "object") {
@@ -246,10 +302,29 @@ function formatPopupEntry(
246302
sourceEntry.validTo,
247303
input.formatDateTime,
248304
input.formatDate,
305+
input.translate,
306+
);
307+
const typeText = localizedTokenList(
308+
sourceEntry.type,
309+
"type",
310+
ROAD_CONDITION_TYPES,
311+
input.translate,
312+
);
313+
const roadStateText = localizedTokenList(
314+
sourceEntry.roadState,
315+
"roadState",
316+
ROAD_CONDITION_STATES,
317+
input.translate,
318+
);
319+
const severityText = localizedTokenList(
320+
sourceEntry.severity,
321+
"sev",
322+
ROAD_CONDITION_SEVERITIES,
323+
input.translate,
249324
);
250325
const startsAt =
251326
sourceEntry.future === true && typeof sourceEntry.validFrom === "string"
252-
? input.translate("startsAt", { date: input.formatDateTime(sourceEntry.validFrom) })
327+
? input.formatDateTime(sourceEntry.validFrom)
253328
: undefined;
254329
const delaySeconds = Number(sourceEntry.delaySeconds);
255330
const delayText =
@@ -258,6 +333,9 @@ function formatPopupEntry(
258333
: undefined;
259334
return {
260335
...sourceEntry,
336+
...(typeText ? { typeText } : {}),
337+
...(roadStateText ? { roadStateText } : {}),
338+
...(severityText ? { severityText } : {}),
261339
...(validity ? { validity } : {}),
262340
...(startsAt ? { startsAt } : {}),
263341
...(delayText ? { delayText } : {}),

packages/i18n/locales/de.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,18 @@
361361
"all": "Alle"
362362
},
363363
"startsAt": "Beginnt {date}",
364+
"schedule": {
365+
"from": "ab",
366+
"days": {
367+
"MO": "Mo",
368+
"TU": "Di",
369+
"WE": "Mi",
370+
"TH": "Do",
371+
"FR": "Fr",
372+
"SA": "Sa",
373+
"SU": "So"
374+
}
375+
},
364376
"status": {
365377
"loading": "Verkehrsstörungen werden aktualisiert …",
366378
"stale": "Verkehrsstörungen sind möglicherweise veraltet.",
@@ -390,11 +402,18 @@
390402
"hazard": "Gefahr",
391403
"obstruction": "Hindernis"
392404
},
405+
"roadState": {
406+
"open": "Offen",
407+
"closed": "Geschlossen",
408+
"some_lanes_closed": "Einige Fahrspuren gesperrt",
409+
"single_lane_alternating": "Einspuriger Wechselverkehr"
410+
},
393411
"panel": {
394412
"type": "Typ",
395413
"roadState": "Status",
396414
"roads": "Betroffene Straßen",
397415
"validity": "Aktiv",
416+
"startsAt": "Beginnt am",
398417
"delay": "Verzögerung",
399418
"description": "Details",
400419
"sourceRecord": "Quelleneintrag",

packages/i18n/locales/en.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,18 @@
361361
"all": "All"
362362
},
363363
"startsAt": "Starts {date}",
364+
"schedule": {
365+
"from": "from",
366+
"days": {
367+
"MO": "Mon",
368+
"TU": "Tue",
369+
"WE": "Wed",
370+
"TH": "Thu",
371+
"FR": "Fri",
372+
"SA": "Sat",
373+
"SU": "Sun"
374+
}
375+
},
364376
"status": {
365377
"loading": "Updating traffic incidents…",
366378
"stale": "Traffic incidents may be outdated.",
@@ -390,11 +402,18 @@
390402
"hazard": "Hazard",
391403
"obstruction": "Obstruction"
392404
},
405+
"roadState": {
406+
"open": "Open",
407+
"closed": "Closed",
408+
"some_lanes_closed": "Some lanes closed",
409+
"single_lane_alternating": "Single-lane alternating traffic"
410+
},
393411
"panel": {
394412
"type": "Type",
395413
"roadState": "Status",
396414
"roads": "Roads affected",
397415
"validity": "Active",
416+
"startsAt": "Starts at",
398417
"delay": "Delay",
399418
"description": "Details",
400419
"sourceRecord": "Source record",

0 commit comments

Comments
 (0)