diff --git a/frontend/e2e/greeting-live.spec.ts b/frontend/e2e/greeting-live.spec.ts new file mode 100644 index 0000000..108934d --- /dev/null +++ b/frontend/e2e/greeting-live.spec.ts @@ -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> = { + "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"); +}); diff --git a/frontend/e2e/greeting.spec.ts b/frontend/e2e/greeting.spec.ts new file mode 100644 index 0000000..ad94aa5 --- /dev/null +++ b/frontend/e2e/greeting.spec.ts @@ -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); +}); diff --git a/frontend/src/i18n/greeting.ts b/frontend/src/i18n/greeting.ts new file mode 100644 index 0000000..4339b5f --- /dev/null +++ b/frontend/src/i18n/greeting.ts @@ -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"; +} diff --git a/frontend/src/i18n/locales/en-GB/dashboard.json b/frontend/src/i18n/locales/en-GB/dashboard.json index 97fdd98..c3a314b 100644 --- a/frontend/src/i18n/locales/en-GB/dashboard.json +++ b/frontend/src/i18n/locales/en-GB/dashboard.json @@ -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": { diff --git a/frontend/src/i18n/locales/fr-BE/dashboard.json b/frontend/src/i18n/locales/fr-BE/dashboard.json index 97c8e6b..a3a757d 100644 --- a/frontend/src/i18n/locales/fr-BE/dashboard.json +++ b/frontend/src/i18n/locales/fr-BE/dashboard.json @@ -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": { diff --git a/frontend/src/i18n/locales/nl-BE/dashboard.json b/frontend/src/i18n/locales/nl-BE/dashboard.json index ce744ec..774829b 100644 --- a/frontend/src/i18n/locales/nl-BE/dashboard.json +++ b/frontend/src/i18n/locales/nl-BE/dashboard.json @@ -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": { diff --git a/frontend/src/i18n/useGreetingPeriod.ts b/frontend/src/i18n/useGreetingPeriod.ts new file mode 100644 index 0000000..fc2bc17 --- /dev/null +++ b/frontend/src/i18n/useGreetingPeriod.ts @@ -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(() => getGreetingPeriod()); + + useEffect(() => { + const id = setInterval(() => { + setPeriod((current) => { + const next = getGreetingPeriod(); + return next === current ? current : next; + }); + }, CHECK_INTERVAL_MS); + return () => clearInterval(id); + }, []); + + return period; +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index e376dcc..4c80768 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 (
- {t("viewFleet")}} /> + {t("viewFleet")}} />