feat: add time-dependent Europe/Brussels dashboard greeting
The dashboard greeting was a fully static "Goedemorgen..." regardless of
actual time of day. New frontend/src/i18n/greeting.ts::getGreetingPeriod
is a pure, clock-injectable function resolving one of 4 periods (05:00-
11:59 morning, 12:00-17:59 afternoon, 18:00-22:59 evening, 23:00-04:59
night) against Europe/Brussels wall-clock time via
Intl.DateTimeFormat({ timeZone, hourCycle: "h23" }), which is DST-safe
by construction.
useGreetingPeriod.ts wires this into React with a 30s poll so the
greeting rolls over live while the app stays open, no reload required.
Each period now has its own greeting word and accompanying sentence in
all 3 languages (dashboard.json), replacing both the fixed "Goedemorgen"
and the fixed "Here's the fleet" follow-up sentence. Night never says
"Goedenacht" (used as a farewell, not a welcome, in Dutch).
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
|
||||
// Browser-context evidence for the time-dependent Europe/Brussels dashboard greeting
|
||||
// (section 9 / 11 / 16 of the Fleet Ops final localization brief): the real rendered app,
|
||||
// in all 3 languages, at every required boundary instant, using Playwright's clock API to
|
||||
// control the browser's Date without waiting on real wall-clock time. Also proves the
|
||||
// greeting updates live (no reload) when the period rolls over while the app stays open.
|
||||
|
||||
// 2026-01-15 is CET (UTC+1): Brussels hour = UTC hour + 1.
|
||||
function cet(hour: number, minute = 0, second = 0): Date {
|
||||
return new Date(Date.UTC(2026, 0, 15, hour - 1, minute, second));
|
||||
}
|
||||
|
||||
const BOUNDARY_CASES: Array<{ time: Date; label: string; period: string }> = [
|
||||
{ time: cet(4, 59), label: "04:59", period: "night" },
|
||||
{ time: cet(5, 0), label: "05:00", period: "morning" },
|
||||
{ time: cet(11, 59), label: "11:59", period: "morning" },
|
||||
{ time: cet(12, 0), label: "12:00", period: "afternoon" },
|
||||
{ time: cet(17, 59), label: "17:59", period: "afternoon" },
|
||||
{ time: cet(18, 0), label: "18:00", period: "evening" },
|
||||
{ time: cet(22, 59), label: "22:59", period: "evening" },
|
||||
{ time: cet(23, 0), label: "23:00", period: "night" },
|
||||
];
|
||||
|
||||
const EXPECTED_TITLE: Record<string, Record<string, string>> = {
|
||||
"nl-BE": {
|
||||
morning: "Goedemorgen. Hier is de status van je wagenpark voor vandaag.",
|
||||
afternoon: "Goedemiddag. Hier is het actuele overzicht van je wagenpark.",
|
||||
evening: "Goedenavond. Hier is het overzicht van je wagenpark voor vanavond.",
|
||||
night: "Welkom terug. Hier is het laatste overzicht van je wagenpark.",
|
||||
},
|
||||
"en-GB": {
|
||||
morning: "Good morning. Here's today's fleet status.",
|
||||
afternoon: "Good afternoon. Here's the current overview of your fleet.",
|
||||
evening: "Good evening. Here's this evening's fleet overview.",
|
||||
night: "Welcome back. Here's the latest overview of your fleet.",
|
||||
},
|
||||
"fr-BE": {
|
||||
morning: "Bonjour. Voici l'état de votre flotte pour aujourd'hui.",
|
||||
afternoon: "Bonjour. Voici l'aperçu actuel de votre flotte.",
|
||||
evening: "Bonsoir. Voici l'aperçu de votre flotte pour ce soir.",
|
||||
night: "Bon retour. Voici le dernier aperçu de votre flotte.",
|
||||
},
|
||||
};
|
||||
|
||||
async function loginAsOperationsManager(page: Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
}
|
||||
|
||||
async function switchLanguage(page: Page, language: "nl-BE" | "en-GB" | "fr-BE") {
|
||||
// At the default desktop viewport, only the topbar's compact switcher is visible --
|
||||
// the sidebar's full switcher is `display: none` until the <960px breakpoint.
|
||||
await page.locator(".language-switcher-compact select").selectOption(language);
|
||||
}
|
||||
|
||||
const dashboardHeading = (page: Page) => page.locator(".page-header h1");
|
||||
|
||||
for (const language of ["nl-BE", "en-GB", "fr-BE"] as const) {
|
||||
test(`dashboard greeting matches every required boundary time in ${language}`, async ({ page }) => {
|
||||
await page.clock.install({ time: BOUNDARY_CASES[0].time });
|
||||
await loginAsOperationsManager(page);
|
||||
if (language !== "nl-BE") {
|
||||
await switchLanguage(page, language);
|
||||
}
|
||||
|
||||
for (const { time, label, period } of BOUNDARY_CASES) {
|
||||
await page.clock.setFixedTime(time);
|
||||
await page.reload();
|
||||
await expect(dashboardHeading(page), `${language} @ ${label} Brussels time (expected period: ${period})`).toHaveText(
|
||||
EXPECTED_TITLE[language][period],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("dashboard greeting updates live across a period rollover without a page reload", async ({ page }) => {
|
||||
// Start at 11:59:31 Brussels -- 29s before the 12:00 boundary.
|
||||
await page.clock.install({ time: cet(11, 59, 31) });
|
||||
await loginAsOperationsManager(page);
|
||||
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning);
|
||||
|
||||
// Advance 30s of fake time (crossing 12:00) so the hook's 30s poll interval fires and
|
||||
// recomputes the period -- no page.reload() call anywhere in this test.
|
||||
await page.clock.fastForward(30_000);
|
||||
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].afternoon);
|
||||
});
|
||||
|
||||
test("dashboard greeting updates immediately on language switch without changing the time period", async ({ page }) => {
|
||||
await page.clock.install({ time: cet(9, 0) });
|
||||
await loginAsOperationsManager(page);
|
||||
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning);
|
||||
|
||||
await switchLanguage(page, "en-GB");
|
||||
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["en-GB"].morning);
|
||||
|
||||
await switchLanguage(page, "fr-BE");
|
||||
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["fr-BE"].morning);
|
||||
});
|
||||
|
||||
test("dashboard never shows 'Goedenacht' as a greeting at any hour", async ({ page }) => {
|
||||
await page.clock.install({ time: cet(2, 0) });
|
||||
await loginAsOperationsManager(page);
|
||||
await expect(dashboardHeading(page)).not.toContainText("Goedenacht");
|
||||
await expect(dashboardHeading(page)).toContainText("Welkom terug");
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { getBrusselsHour, getGreetingPeriod } from "../src/i18n/greeting";
|
||||
|
||||
// Pure Node-context boundary tests for the central, clock-injectable greeting function
|
||||
// (section 9 of the Fleet Ops final localization brief). Every case below constructs an
|
||||
// explicit UTC instant that corresponds to a specific Europe/Brussels wall-clock time --
|
||||
// this is what "clock injection" buys: no real time needs to pass, and DST is exercised
|
||||
// by picking instants either side of the CET/CEST transition.
|
||||
|
||||
// 2026-01-15 is CET (UTC+1): 04:59 Brussels = 03:59 UTC.
|
||||
function cet(hour: number, minute = 0): Date {
|
||||
return new Date(Date.UTC(2026, 0, 15, hour - 1, minute));
|
||||
}
|
||||
|
||||
// 2026-07-15 is CEST (UTC+2): 04:59 Brussels = 02:59 UTC.
|
||||
function cest(hour: number, minute = 0): Date {
|
||||
return new Date(Date.UTC(2026, 6, 15, hour - 2, minute));
|
||||
}
|
||||
|
||||
test("period boundaries are correct in winter time (CET, UTC+1)", () => {
|
||||
expect(getGreetingPeriod(cet(4, 59))).toBe("night");
|
||||
expect(getGreetingPeriod(cet(5, 0))).toBe("morning");
|
||||
expect(getGreetingPeriod(cet(11, 59))).toBe("morning");
|
||||
expect(getGreetingPeriod(cet(12, 0))).toBe("afternoon");
|
||||
expect(getGreetingPeriod(cet(17, 59))).toBe("afternoon");
|
||||
expect(getGreetingPeriod(cet(18, 0))).toBe("evening");
|
||||
expect(getGreetingPeriod(cet(22, 59))).toBe("evening");
|
||||
expect(getGreetingPeriod(cet(23, 0))).toBe("night");
|
||||
});
|
||||
|
||||
test("period boundaries are correct in summer time (CEST, UTC+2)", () => {
|
||||
expect(getGreetingPeriod(cest(4, 59))).toBe("night");
|
||||
expect(getGreetingPeriod(cest(5, 0))).toBe("morning");
|
||||
expect(getGreetingPeriod(cest(11, 59))).toBe("morning");
|
||||
expect(getGreetingPeriod(cest(12, 0))).toBe("afternoon");
|
||||
expect(getGreetingPeriod(cest(17, 59))).toBe("afternoon");
|
||||
expect(getGreetingPeriod(cest(18, 0))).toBe("evening");
|
||||
expect(getGreetingPeriod(cest(22, 59))).toBe("evening");
|
||||
expect(getGreetingPeriod(cest(23, 0))).toBe("night");
|
||||
});
|
||||
|
||||
test("DST transition (2026-03-29, clocks spring forward 02:00 -> 03:00 CEST): the Brussels hour never regresses or skips a period incorrectly", () => {
|
||||
// 00:30 UTC = 01:30 CET, still "night" (before the 05:00 boundary regardless).
|
||||
const beforeTransition = new Date(Date.UTC(2026, 2, 29, 0, 30));
|
||||
expect(getBrusselsHour(beforeTransition)).toBe(1);
|
||||
expect(getGreetingPeriod(beforeTransition)).toBe("night");
|
||||
|
||||
// 09:00 UTC on transition day = 11:00 CEST (already sprung forward) -- still morning.
|
||||
const afterTransition = new Date(Date.UTC(2026, 2, 29, 9, 0));
|
||||
expect(getBrusselsHour(afterTransition)).toBe(11);
|
||||
expect(getGreetingPeriod(afterTransition)).toBe("morning");
|
||||
|
||||
// Autumn transition, 2026-10-25: fall-back happens at 01:00 UTC (03:00 CEST -> 02:00
|
||||
// CET), so 00:30 UTC is still CEST -> 02:30 Brussels.
|
||||
const beforeFallBack = new Date(Date.UTC(2026, 9, 25, 0, 30));
|
||||
expect(getBrusselsHour(beforeFallBack)).toBe(2);
|
||||
expect(getGreetingPeriod(beforeFallBack)).toBe("night");
|
||||
|
||||
// 09:00 UTC on fall-back day = 10:00 CET (already fallen back) -- still morning.
|
||||
const afterFallBack = new Date(Date.UTC(2026, 9, 25, 9, 0));
|
||||
expect(getBrusselsHour(afterFallBack)).toBe(10);
|
||||
expect(getGreetingPeriod(afterFallBack)).toBe("morning");
|
||||
});
|
||||
|
||||
test("default argument uses the real current time when no clock is injected", () => {
|
||||
const period = getGreetingPeriod();
|
||||
expect(["morning", "afternoon", "evening", "night"]).toContain(period);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
export type GreetingPeriod = "morning" | "afternoon" | "evening" | "night";
|
||||
|
||||
const BRUSSELS_TIME_ZONE = "Europe/Brussels";
|
||||
|
||||
// DST-safe: Intl.DateTimeFormat resolves the correct Europe/Brussels wall-clock hour for
|
||||
// any instant, automatically accounting for the CET/CEST transition -- no manual UTC
|
||||
// offset math (which would silently break twice a year) is needed. `hourCycle: "h23"`
|
||||
// pins the output to a plain 0-23 range (some engines otherwise render midnight as "24").
|
||||
export function getBrusselsHour(date: Date): number {
|
||||
const formatter = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: BRUSSELS_TIME_ZONE,
|
||||
hour: "numeric",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
return Number(formatter.format(date));
|
||||
}
|
||||
|
||||
// Boundaries per the Fleet Ops final localization brief (section 9):
|
||||
// 05:00-11:59 morning, 12:00-17:59 afternoon, 18:00-22:59 evening, 23:00-04:59 night.
|
||||
// `date` defaults to `new Date()` but accepts any Date so callers (and tests) can inject
|
||||
// a fixed clock instead of depending on the real wall clock.
|
||||
export function getGreetingPeriod(date: Date = new Date()): GreetingPeriod {
|
||||
const hour = getBrusselsHour(date);
|
||||
if (hour >= 5 && hour < 12) return "morning";
|
||||
if (hour >= 12 && hour < 18) return "afternoon";
|
||||
if (hour >= 18 && hour < 23) return "evening";
|
||||
return "night";
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
{
|
||||
"eyebrow": "Operations / Live overview",
|
||||
"title": "Good morning. Here's the fleet.",
|
||||
"greeting": {
|
||||
"morning": "Good morning",
|
||||
"afternoon": "Good afternoon",
|
||||
"evening": "Good evening",
|
||||
"night": "Welcome back"
|
||||
},
|
||||
"greetingBody": {
|
||||
"morning": "Here's today's fleet status.",
|
||||
"afternoon": "Here's the current overview of your fleet.",
|
||||
"evening": "Here's this evening's fleet overview.",
|
||||
"night": "Here's the latest overview of your fleet."
|
||||
},
|
||||
"description": "Readiness, exceptions and hand-offs across today's operation.",
|
||||
"viewFleet": "View fleet",
|
||||
"demoStart": {
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
{
|
||||
"eyebrow": "Exploitation / Aperçu en direct",
|
||||
"title": "Bonjour. Voici votre flotte.",
|
||||
"greeting": {
|
||||
"morning": "Bonjour",
|
||||
"afternoon": "Bonjour",
|
||||
"evening": "Bonsoir",
|
||||
"night": "Bon retour"
|
||||
},
|
||||
"greetingBody": {
|
||||
"morning": "Voici l'état de votre flotte pour aujourd'hui.",
|
||||
"afternoon": "Voici l'aperçu actuel de votre flotte.",
|
||||
"evening": "Voici l'aperçu de votre flotte pour ce soir.",
|
||||
"night": "Voici le dernier aperçu de votre flotte."
|
||||
},
|
||||
"description": "Disponibilité, exceptions et transferts de l'activité en cours.",
|
||||
"viewFleet": "Voir la flotte",
|
||||
"demoStart": {
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
{
|
||||
"eyebrow": "Uitvoeren / Live overzicht",
|
||||
"title": "Goedemorgen. Hier is je wagenpark.",
|
||||
"greeting": {
|
||||
"morning": "Goedemorgen",
|
||||
"afternoon": "Goedemiddag",
|
||||
"evening": "Goedenavond",
|
||||
"night": "Welkom terug"
|
||||
},
|
||||
"greetingBody": {
|
||||
"morning": "Hier is de status van je wagenpark voor vandaag.",
|
||||
"afternoon": "Hier is het actuele overzicht van je wagenpark.",
|
||||
"evening": "Hier is het overzicht van je wagenpark voor vanavond.",
|
||||
"night": "Hier is het laatste overzicht van je wagenpark."
|
||||
},
|
||||
"description": "Beschikbaarheid, uitzonderingen en overdrachten doorheen de huidige werking.",
|
||||
"viewFleet": "Wagenpark bekijken",
|
||||
"demoStart": {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getGreetingPeriod, type GreetingPeriod } from "./greeting";
|
||||
|
||||
// Checked frequently enough that a period rollover (e.g. 11:59 -> 12:00) is picked up
|
||||
// within the Dashboard while the app stays open, without a page reload -- see section 9
|
||||
// of the Fleet Ops final localization brief.
|
||||
const CHECK_INTERVAL_MS = 30_000;
|
||||
|
||||
export function useGreetingPeriod(): GreetingPeriod {
|
||||
const [period, setPeriod] = useState<GreetingPeriod>(() => getGreetingPeriod());
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setPeriod((current) => {
|
||||
const next = getGreetingPeriod();
|
||||
return next === current ? current : next;
|
||||
});
|
||||
}, CHECK_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return period;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useAuth } from "../context/AuthContext";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { useGreetingPeriod } from "../i18n/useGreetingPeriod";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||
@@ -31,6 +32,7 @@ function attentionItemTitle(
|
||||
export function Dashboard() {
|
||||
const { t } = useTranslation(["dashboard", "common", "integrations"]);
|
||||
const { formatTime, formatShortDate } = useLocaleFormat();
|
||||
const greetingPeriod = useGreetingPeriod();
|
||||
const { user } = useAuth();
|
||||
const { manifest } = useDemoManifest();
|
||||
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
|
||||
@@ -78,10 +80,11 @@ export function Dashboard() {
|
||||
|
||||
const latestRun = data.recent_automation[0];
|
||||
const readyCount = manifest?.scenarios.filter((s) => s.ready).length ?? 0;
|
||||
const greetingTitle = `${t(`greeting.${greetingPeriod}`)}. ${t(`greetingBody.${greetingPeriod}`)}`;
|
||||
|
||||
return (
|
||||
<div className="page dashboard-page">
|
||||
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> {t("viewFleet")}</Link>} />
|
||||
<PageHeader eyebrow={t("eyebrow")} title={greetingTitle} description={t("description")} actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> {t("viewFleet")}</Link>} />
|
||||
|
||||
<section className="demo-start-panel" aria-label={t("demoStart.title")}>
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user