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
This commit is contained in:
Pedro Castro
2026-01-13 08:47:39 -03:00
committed by GitHub
parent aaf5958b7d
commit 1e67f98970
8 changed files with 516 additions and 18 deletions
@@ -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 (
<h3 className="min-w-[150px] text-base font-semibold leading-4">
{formattedMonth.format(selectedDate.toDate())} {selectedDate.format("D")}
{formatMonthLabel(selectedDate.toDate())} {selectedDate.format("D")}
{!isSameYear() && <span className="text-subtle">, {selectedDate.format("YYYY")} </span>}-{" "}
{!isSameMonth() && formattedMonth.format(endDate.toDate())} {endDate.format("D")},{" "}
{!isSameMonth() && formatMonthLabel(endDate.toDate())} {endDate.format("D")},{" "}
<span className="text-subtle">
{isSameYear() ? selectedDate.format("YYYY") : endDate.format("YYYY")}
</span>
+3
View File
@@ -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";
@@ -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,
@@ -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({
+341
View File
@@ -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
});
});
});
+134
View File
@@ -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<string, boolean>();
const formatterCache = new Map<string, Intl.DateTimeFormat>();
const weekdayCache = new Map<string, string[]>();
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;
}
+8
View File
@@ -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
});
});
});
+6 -5
View File
@@ -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);
}