From 1e67f98970459cfdcba14d70ba55d27152f723f5 Mon Sep 17 00:00:00 2001 From: Pedro Castro Date: Tue, 13 Jan 2026 08:47:39 -0300 Subject: [PATCH] fix: add Icelandic, Lithuanian, and Norwegian locale support for Booker (#25519) - Create unified date/time formatter with Intl-first approach and dayjs fallback - Add support for is/lt/nb locales in Booker components - Unify date formatting across Booker (Header, dates, weekdays) - Update translation pipeline to include is, lt, nb - Add tests for new locales and formatter - Add timezone support in dayjs fallback - Add formatter caching with circuit breaker - Use unified formatDateTime in Header.tsx --- .../modules/bookings/components/Header.tsx | 8 +- packages/dayjs/locales.ts | 3 + .../features/bookings/Booker/utils/dates.tsx | 26 +- .../tasker/tasks/translateEventTypeData.ts | 3 + packages/lib/dateTimeFormatter.test.ts | 341 ++++++++++++++++++ packages/lib/dateTimeFormatter.ts | 134 +++++++ packages/lib/weekday.test.ts | 8 + packages/lib/weekday.ts | 11 +- 8 files changed, 516 insertions(+), 18 deletions(-) create mode 100644 packages/lib/dateTimeFormatter.test.ts create mode 100644 packages/lib/dateTimeFormatter.ts diff --git a/apps/web/modules/bookings/components/Header.tsx b/apps/web/modules/bookings/components/Header.tsx index 2de49bbe99..166c417900 100644 --- a/apps/web/modules/bookings/components/Header.tsx +++ b/apps/web/modules/bookings/components/Header.tsx @@ -7,6 +7,7 @@ import { useIsEmbed } from "@calcom/embed-core/embed-iframe"; import { useBookerStoreContext } from "@calcom/features/bookings/Booker/BookerStoreProvider"; import { useInitializeWeekStart } from "@calcom/features/bookings/Booker/components/hooks/useInitializeWeekStart"; import { WEBAPP_URL } from "@calcom/lib/constants"; +import { formatDateTime } from "@calcom/lib/dateTimeFormatter"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; import { Button } from "@calcom/ui/components/button"; @@ -97,13 +98,14 @@ export function Header({ const isSameYear = () => { return selectedDate.format("YYYY") === endDate.format("YYYY"); }; - const formattedMonth = new Intl.DateTimeFormat(i18n.language ?? "en", { month: "short" }); + const formatMonthLabel = (date: Date) => + formatDateTime(date, { locale: i18n.language ?? "en", month: "short" }); const FormattedSelectedDateRange = () => { return (

- {formattedMonth.format(selectedDate.toDate())} {selectedDate.format("D")} + {formatMonthLabel(selectedDate.toDate())} {selectedDate.format("D")} {!isSameYear() && , {selectedDate.format("YYYY")} }-{" "} - {!isSameMonth() && formattedMonth.format(endDate.toDate())} {endDate.format("D")},{" "} + {!isSameMonth() && formatMonthLabel(endDate.toDate())} {endDate.format("D")},{" "} {isSameYear() ? selectedDate.format("YYYY") : endDate.format("YYYY")} diff --git a/packages/dayjs/locales.ts b/packages/dayjs/locales.ts index 7310aadd30..f7ee7fb840 100644 --- a/packages/dayjs/locales.ts +++ b/packages/dayjs/locales.ts @@ -8,9 +8,12 @@ import "dayjs/locale/fi"; import "dayjs/locale/fr"; import "dayjs/locale/he"; import "dayjs/locale/hu"; +import "dayjs/locale/is"; import "dayjs/locale/it"; import "dayjs/locale/ja"; import "dayjs/locale/ko"; +import "dayjs/locale/lt"; +import "dayjs/locale/nb"; import "dayjs/locale/nl"; import "dayjs/locale/pl"; import "dayjs/locale/pt"; diff --git a/packages/features/bookings/Booker/utils/dates.tsx b/packages/features/bookings/Booker/utils/dates.tsx index 6d123085f7..d7d7b9e666 100644 --- a/packages/features/bookings/Booker/utils/dates.tsx +++ b/packages/features/bookings/Booker/utils/dates.tsx @@ -1,3 +1,4 @@ +import { formatDateTime, formatDateTimeRange } from "@calcom/lib/dateTimeFormatter"; import { TimeFormat } from "@calcom/lib/timeFormat"; interface EventFromToTime { @@ -17,16 +18,19 @@ interface EventFromTime { export const formatEventFromTime = ({ date, timeFormat, timeZone, language }: EventFromTime) => { const startDate = new Date(date); - const formattedDate = new Intl.DateTimeFormat(language, { + + const formattedDate = formatDateTime(startDate, { + locale: language, timeZone, dateStyle: "full", - }).format(startDate); + }); - const formattedTime = new Intl.DateTimeFormat(language, { + const formattedTime = formatDateTime(startDate, { + locale: language, timeZone, timeStyle: "short", - hour12: timeFormat === TimeFormat.TWELVE_HOUR ? true : false, - }).format(startDate); + hour12: timeFormat === TimeFormat.TWELVE_HOUR, + }); return { date: formattedDate, @@ -49,16 +53,18 @@ export const formatEventFromToTime = ({ ? new Date(new Date(date).setMinutes(startDate.getMinutes() + duration)) : startDate; - const formattedDate = new Intl.DateTimeFormat(language, { + const formattedDate = formatDateTimeRange(startDate, endDate, { + locale: language, timeZone, dateStyle: "full", - }).formatRange(startDate, endDate); + }); - const formattedTime = new Intl.DateTimeFormat(language, { + const formattedTime = formatDateTimeRange(startDate, endDate, { + locale: language, timeZone, timeStyle: "short", - hour12: timeFormat === TimeFormat.TWELVE_HOUR ? true : false, - }).formatRange(startDate, endDate); + hour12: timeFormat === TimeFormat.TWELVE_HOUR, + }); return { date: formattedDate, diff --git a/packages/features/tasker/tasks/translateEventTypeData.ts b/packages/features/tasker/tasks/translateEventTypeData.ts index 20f131537b..bbcc058cae 100644 --- a/packages/features/tasker/tasks/translateEventTypeData.ts +++ b/packages/features/tasker/tasks/translateEventTypeData.ts @@ -30,6 +30,9 @@ const SUPPORTED_LOCALES = [ "ja", // Japanese "sv", // Swedish "da", // Danish + "is", // Icelandic + "lt", // Lithuanian + "nb", // Norwegian Bokmål ] as const; async function processTranslations({ diff --git a/packages/lib/dateTimeFormatter.test.ts b/packages/lib/dateTimeFormatter.test.ts new file mode 100644 index 0000000000..ed2f69765d --- /dev/null +++ b/packages/lib/dateTimeFormatter.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from "vitest"; + +import "@calcom/dayjs/locales"; + +import { formatDateTime, formatDateTimeRange, formatWeekday, getWeekdayNames } from "./dateTimeFormatter"; + +describe("dateTimeFormatter", () => { + describe("formatDateTime", () => { + describe("supported locales (Intl path)", () => { + it("formats full date style in English", () => { + const date = new Date("2025-12-01T10:30:00Z"); + + const result = formatDateTime(date, { + locale: "en-US", + timeZone: "UTC", + dateStyle: "full", + }); + + // Validate actual content + expect(result).toContain("Monday"); + expect(result).toContain("December"); + expect(result).toContain("1"); + expect(result).toContain("2025"); + }); + + it("formats time with 12h format", () => { + const date = new Date("2025-12-01T14:30:00Z"); + + const result = formatDateTime(date, { + locale: "en-US", + timeZone: "UTC", + timeStyle: "short", + hour12: true, + }); + + expect(result).toMatch(/2:30\s*PM/); + }); + + it("formats month only", () => { + const date = new Date("2025-12-01T10:30:00Z"); + + const result = formatDateTime(date, { + locale: "en-US", + month: "long", + }); + + expect(result).toBe("December"); + }); + }); + + describe("unsupported locales (dayjs fallback)", () => { + it("formats Icelandic date correctly", () => { + const date = new Date("2025-12-01T10:30:00Z"); + + const result = formatDateTime(date, { + locale: "is", + dateStyle: "long", + }); + + // Validate Icelandic content + expect(result).toMatch(/desember/i); // December in Icelandic + expect(result).toContain("1"); + expect(result).toContain("2025"); + }); + + it("formats Lithuanian date correctly", () => { + const date = new Date("2025-12-01T10:30:00Z"); + + const result = formatDateTime(date, { + locale: "lt", + dateStyle: "long", + }); + + // Validate Lithuanian content + expect(result).toMatch(/gruod/i); // December in Lithuanian + expect(result).toContain("1"); + expect(result).toContain("2025"); + }); + + it("formats Norwegian Bokmål date correctly", () => { + const date = new Date("2025-12-01T10:30:00Z"); + + const result = formatDateTime(date, { + locale: "nb", + dateStyle: "long", + }); + + // Validate Norwegian content + expect(result).toMatch(/desember/i); // December in Norwegian + expect(result).toContain("1"); + expect(result).toContain("2025"); + }); + + it("formats month-only in Icelandic", () => { + const date = new Date("2025-12-01T10:30:00Z"); + + const result = formatDateTime(date, { + locale: "is", + month: "long", + }); + + expect(result).toMatch(/desember/i); + }); + + it("formats narrow month (first character)", () => { + const date = new Date("2025-12-01T10:30:00Z"); + + const result = formatDateTime(date, { + locale: "is", + month: "narrow", + }); + + expect(result).toHaveLength(1); + }); + + it("formats time with 12h format in Icelandic", () => { + const date = new Date("2025-12-01T14:30:00Z"); + + const result = formatDateTime(date, { + locale: "is", + timeStyle: "short", + hour12: true, + }); + + expect(result).toMatch(/2:30/); + expect(result).toMatch(/(PM|e\.h\.)/i); // English PM or Icelandic "eftir hádegi" + }); + + it("formats combined date and time", () => { + const date = new Date("2025-12-01T14:30:00Z"); + + const result = formatDateTime(date, { + locale: "is", + dateStyle: "medium", + timeStyle: "short", + hour12: false, + }); + + expect(result).toContain("1"); + expect(result).toMatch(/14:30/); + }); + }); + }); + + describe("formatDateTimeRange", () => { + describe("supported locales", () => { + it("formats time range on same day", () => { + const startDate = new Date("2025-12-01T10:30:00Z"); + const endDate = new Date("2025-12-01T11:30:00Z"); + + const result = formatDateTimeRange(startDate, endDate, { + locale: "en-US", + timeZone: "UTC", + timeStyle: "short", + }); + + expect(result).toMatch(/10:30/); + expect(result).toMatch(/11:30/); + }); + + it("formats date range across different days", () => { + const startDate = new Date("2025-12-01T10:30:00Z"); + const endDate = new Date("2025-12-02T11:30:00Z"); + + const result = formatDateTimeRange(startDate, endDate, { + locale: "en-US", + timeZone: "UTC", + dateStyle: "short", + }); + + expect(result).toContain("12/1"); + expect(result).toContain("12/2"); + }); + }); + + describe("unsupported locales (dayjs fallback)", () => { + it("formats time range in Icelandic", () => { + const startDate = new Date("2025-12-01T10:30:00Z"); + const endDate = new Date("2025-12-01T11:30:00Z"); + + const result = formatDateTimeRange(startDate, endDate, { + locale: "is", + timeZone: "UTC", + timeStyle: "short", + hour12: false, + }); + + expect(result).toContain("10:30"); + expect(result).toContain("11:30"); + expect(result).toContain("–"); // En dash separator + }); + + it("formats date range in Lithuanian", () => { + const startDate = new Date("2025-12-01T10:30:00Z"); + const endDate = new Date("2025-12-02T10:30:00Z"); + + const result = formatDateTimeRange(startDate, endDate, { + locale: "lt", + dateStyle: "medium", + }); + + expect(result).toContain("–"); + expect(result).toContain("1"); + expect(result).toContain("2"); + }); + }); + }); + + describe("formatWeekday", () => { + describe("supported locales", () => { + it("formats long weekday names in English", () => { + expect(formatWeekday("en-US", 0, "long")).toBe("Sunday"); + expect(formatWeekday("en-US", 1, "long")).toBe("Monday"); + expect(formatWeekday("en-US", 6, "long")).toBe("Saturday"); + }); + + it("formats short weekday names in English", () => { + expect(formatWeekday("en-US", 0, "short")).toBe("Sun"); + expect(formatWeekday("en-US", 1, "short")).toBe("Mon"); + }); + }); + + describe("unsupported locales (dayjs fallback)", () => { + it("formats long weekday names in Icelandic", () => { + const monday = formatWeekday("is", 1, "long"); + expect(monday).toMatch(/mánudagur/i); + }); + + it("formats short weekday names in Icelandic", () => { + const monday = formatWeekday("is", 1, "short"); + expect(monday).toMatch(/mán/i); + }); + + it("formats weekdays in Lithuanian", () => { + const monday = formatWeekday("lt", 1, "long"); + expect(monday).toMatch(/pirmadienis/i); // Monday in Lithuanian + }); + + it("formats weekdays in Norwegian Bokmål", () => { + const monday = formatWeekday("nb", 1, "long"); + expect(monday).toMatch(/mandag/i); // Monday in Norwegian + }); + }); + }); + + describe("getWeekdayNames", () => { + it("returns 7 weekday names starting Sunday by default", () => { + const result = getWeekdayNames("en-US"); + + expect(result).toHaveLength(7); + expect(result[0]).toBe("Sunday"); + expect(result[1]).toBe("Monday"); + expect(result[6]).toBe("Saturday"); + }); + + it("supports custom week start (Monday)", () => { + const result = getWeekdayNames("en-US", 1); + + expect(result).toHaveLength(7); + expect(result[0]).toBe("Monday"); + expect(result[6]).toBe("Sunday"); + }); + + it("supports short format", () => { + const result = getWeekdayNames("en-US", 0, "short"); + + expect(result).toHaveLength(7); + expect(result[0]).toBe("Sun"); + expect(result[1]).toBe("Mon"); + }); + + it("returns Icelandic weekday names", () => { + const result = getWeekdayNames("is", 0, "long"); + + expect(result).toHaveLength(7); + // Validate at least one Icelandic weekday + expect(result.some((day) => day.match(/dagur$/i))).toBe(true); + }); + }); + + describe("timezone handling", () => { + it("formats in UTC correctly for both Intl and dayjs paths", () => { + const date = new Date("2025-12-01T10:30:00Z"); + + // Intl path (supported locale) + const intlResult = formatDateTime(date, { + locale: "en", + timeZone: "UTC", + timeStyle: "short", + hour12: false, + }); + expect(intlResult).toContain("10:30"); + + // Dayjs path (unsupported locale) + const dayjsResult = formatDateTime(date, { + locale: "is", + timeZone: "UTC", + timeStyle: "short", + hour12: false, + }); + expect(dayjsResult).toContain("10:30"); + }); + + it("applies Tokyo timezone offset correctly", () => { + const date = new Date("2025-12-01T10:30:00Z"); + + // Intl path + const intlResult = formatDateTime(date, { + locale: "en", + timeZone: "Asia/Tokyo", + timeStyle: "short", + hour12: false, + }); + expect(intlResult).toContain("19:30"); // UTC+9 + + // Test timezone respect in dayjs fallback + const dayjsResult = formatDateTime(date, { + locale: "is", + timeZone: "Asia/Tokyo", + timeStyle: "short", + hour12: false, + }); + expect(dayjsResult).toContain("19:30"); // UTC+9 + }); + + it("handles day boundary crossing with timezone offset", () => { + const date = new Date("2025-12-01T20:30:00Z"); + + const result = formatDateTime(date, { + locale: "is", + timeZone: "Asia/Tokyo", + dateStyle: "short", + timeStyle: "short", + hour12: false, + }); + // 20:30 UTC + 9h = 05:30 next day + expect(result).toContain("05:30"); + expect(result).toContain("2"); // December 2nd + }); + }); +}); diff --git a/packages/lib/dateTimeFormatter.ts b/packages/lib/dateTimeFormatter.ts new file mode 100644 index 0000000000..be22596e02 --- /dev/null +++ b/packages/lib/dateTimeFormatter.ts @@ -0,0 +1,134 @@ +import dayjs from "@calcom/dayjs"; + +type DateTimeStyle = "full" | "long" | "medium" | "short"; + +interface DateTimeFormatOptions { + locale: string; + timeZone?: string; + dateStyle?: DateTimeStyle; + timeStyle?: DateTimeStyle; + month?: "numeric" | "2-digit" | "long" | "short" | "narrow"; + hour12?: boolean; +} + +const MAX_FORMATTER_CACHE = 1000; +const MAX_WEEKDAY_CACHE = 500; + +const WEEKDAY_FORMATS = { short: "ddd", long: "dddd" } as const; +const MONTH_FORMATS = { long: "MMMM", short: "MMM", numeric: "M", "2-digit": "MM", narrow: "MMM" } as const; +const DATE_FORMATS = { + full: "dddd, MMMM D, YYYY", + long: "MMMM D, YYYY", + medium: "MMM D, YYYY", + short: "M/D/YY", +} as const; + +const localeSupport = new Map(); +const formatterCache = new Map(); +const weekdayCache = new Map(); + +function isSupported(locale: string): boolean { + let supported = localeSupport.get(locale); + if (supported === undefined) { + supported = Intl.DateTimeFormat.supportedLocalesOf([locale]).length > 0; + localeSupport.set(locale, supported); + } + return supported; +} + +function getFormatter(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat { + const key = `${locale}|${JSON.stringify(options)}`; + let formatter = formatterCache.get(key); + + if (!formatter) { + if (formatterCache.size >= MAX_FORMATTER_CACHE) { + formatterCache.clear(); + } + formatter = new Intl.DateTimeFormat(locale, options); + formatterCache.set(key, formatter); + } + + return formatter; +} + +function buildIntlOptions(options: DateTimeFormatOptions, includeMonth = true): Intl.DateTimeFormatOptions { + const { timeZone, dateStyle, timeStyle, month, hour12 } = options; + return { + ...(timeZone && { timeZone }), + ...(dateStyle && { dateStyle }), + ...(timeStyle && { timeStyle }), + ...(includeMonth && month && { month }), + ...(hour12 !== undefined && { hour12 }), + }; +} + +function formatWithDayjs(date: Date, options: DateTimeFormatOptions): string { + const { locale, dateStyle, timeStyle, month, hour12, timeZone } = options; + const dayjsDate = timeZone ? dayjs(date).tz(timeZone).locale(locale) : dayjs(date).locale(locale); + + if (month) { + const formatted = dayjsDate.format(MONTH_FORMATS[month] || "MMM"); + return month === "narrow" ? formatted.charAt(0) : formatted; + } + + const parts: string[] = []; + if (dateStyle) parts.push(DATE_FORMATS[dateStyle]); + if (timeStyle) parts.push(hour12 ? "h:mm A" : "HH:mm"); + + return dayjsDate.format(parts.join(" ") || "YYYY-MM-DD HH:mm"); +} + +function getDateForWeekday(day: number): Date { + const today = new Date(); + const diff = day - today.getDay(); + const target = new Date(today); + target.setDate(today.getDate() + diff); + return target; +} + +export function formatDateTime(date: Date, options: DateTimeFormatOptions): string { + if (isSupported(options.locale)) { + return getFormatter(options.locale, buildIntlOptions(options)).format(date); + } + return formatWithDayjs(date, options); +} + +export function formatDateTimeRange(startDate: Date, endDate: Date, options: DateTimeFormatOptions): string { + if (isSupported(options.locale)) { + return getFormatter(options.locale, buildIntlOptions(options, false)).formatRange(startDate, endDate); + } + return `${formatWithDayjs(startDate, options)} — ${formatWithDayjs(endDate, options)}`; +} + +export function formatWeekday(locale: string, day: number, format: "short" | "long"): string { + if (isSupported(locale)) { + return getFormatter(locale, { weekday: format }).format(getDateForWeekday(day)); + } + return dayjs().day(day).locale(locale).format(WEEKDAY_FORMATS[format]); +} + +export function getWeekdayNames(locale: string, weekStart = 0, format: "short" | "long" = "long"): string[] { + const cacheKey = `${locale}|${weekStart}|${format}`; + let names = weekdayCache.get(cacheKey); + + if (!names) { + if (weekdayCache.size >= MAX_WEEKDAY_CACHE) { + weekdayCache.clear(); + } + + if (isSupported(locale)) { + const formatter = getFormatter(locale, { weekday: format }); + names = Array.from({ length: 7 }, (_, i) => formatter.format(getDateForWeekday((i + weekStart) % 7))); + } else { + names = Array.from({ length: 7 }, (_, i) => + dayjs() + .day((i + weekStart) % 7) + .locale(locale) + .format(WEEKDAY_FORMATS[format]) + ); + } + weekdayCache.set(cacheKey, names); + } + + return names; +} diff --git a/packages/lib/weekday.test.ts b/packages/lib/weekday.test.ts index 076a618562..e1e3ee6730 100644 --- a/packages/lib/weekday.test.ts +++ b/packages/lib/weekday.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import "@calcom/dayjs/locales"; + import { nameOfDay, weekdayNames } from "./weekday"; describe("Weekday tests", () => { @@ -62,5 +64,11 @@ describe("Weekday tests", () => { } } }); + + it("should work with Icelandic, Lithuanian, and Norwegian locales", () => { + expect(nameOfDay("is", 1, "long")).toMatch(/mánudagur/i); // Monday in Icelandic + expect(nameOfDay("lt", 1, "long")).toMatch(/pirmadienis/i); // Monday in Lithuanian + expect(nameOfDay("nb", 1, "long")).toMatch(/mandag/i); // Monday in Norwegian + }); }); }); diff --git a/packages/lib/weekday.ts b/packages/lib/weekday.ts index 517e8253d2..e2c27ff9ec 100644 --- a/packages/lib/weekday.ts +++ b/packages/lib/weekday.ts @@ -1,10 +1,10 @@ +import { getWeekdayNames, formatWeekday } from "./dateTimeFormatter"; + type WeekdayFormat = "short" | "long"; -// By default starts on Sunday (Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday) export function weekdayNames(locale: string | string[], weekStart = 0, format: WeekdayFormat = "long") { - return Array(7) - .fill(null) - .map((_, day) => nameOfDay(locale, day + weekStart, format)); + const normalizedLocale = Array.isArray(locale) ? locale[0] : locale || "en"; + return getWeekdayNames(normalizedLocale, weekStart, format); } export function nameOfDay( @@ -12,5 +12,6 @@ export function nameOfDay( day: number, format: WeekdayFormat = "long" ) { - return new Intl.DateTimeFormat(locale, { weekday: format }).format(new Date(1970, 0, day + 4)); + const normalizedLocale = Array.isArray(locale) ? locale[0] : locale || "en"; + return formatWeekday(normalizedLocale, day, format); }