Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"es-419",
"eu",
"et",
"fa",
"fi",
"fr",
"he",
Expand Down
32 changes: 32 additions & 0 deletions packages/i18n/locales/fa/common.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"today": "امروز",
"view_previous_month": "مشاهده ماه قبل",
"view_next_month": "مشاهده ماه بعد",
"select": "انتخاب کنید...",
"save": "ذخیره",
"cancel": "لغو",
"back": "بازگشت",
"next": "بعدی",
"date": "تاریخ",
"time": "زمان",
"timezone": "منطقه زمانی",
"confirm": "تأیید",
"loading": "در حال بارگذاری...",
"duration": "مدت زمان",
"no_available_slots": "زمان خالی موجود نیست",
"reschedule": "زمان‌بندی مجدد",
"required": "الزامی",
"submit": "ارسال",
"continue": "ادامه",
"close": "بستن",
"search": "جستجو",
"settings": "تنظیمات",
"general": "عمومی",
"language": "زبان",
"availability": "در دسترس بودن",
"event_type": "نوع رویداد",
"bookings": "رزروها",
"upcoming": "آینده",
"past": "گذشته",
"minute_timeUnit": "دقیقه"
}
47 changes: 46 additions & 1 deletion packages/lib/dateTimeFormatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { describe, expect, it } from "vitest";

import "@calcom/dayjs/locales";

import { formatDateTime, formatDateTimeRange, formatWeekday, getWeekdayNames } from "./dateTimeFormatter";
import {
formatDateTime,
formatDateTimeRange,
formatWeekday,
getWeekdayNames,
isPersianCalendarLocale,
} from "./dateTimeFormatter";

describe("dateTimeFormatter", () => {
describe("formatDateTime", () => {
Expand Down Expand Up @@ -278,6 +284,45 @@ describe("dateTimeFormatter", () => {
});
});

describe("Persian (Shamsi) calendar", () => {
// 2025-12-01 (Gregorian) corresponds to 10 Azar 1404 in the Jalali calendar.
const date = new Date("2025-12-01T10:30:00Z");

it("detects Persian locales", () => {
expect(isPersianCalendarLocale("fa")).toBe(true);
expect(isPersianCalendarLocale("fa-IR")).toBe(true);
expect(isPersianCalendarLocale("en-US-u-ca-persian")).toBe(true);
expect(isPersianCalendarLocale("en")).toBe(false);
expect(isPersianCalendarLocale("fawn")).toBe(false);
});

it("formats dates using the Jalali calendar, not Gregorian", () => {
const result = formatDateTime(date, { locale: "fa", timeZone: "UTC", dateStyle: "long" });

expect(result).toContain("آذر"); // Azar (Jalali month)
expect(result).toContain("۱۴۰۴"); // Jalali year 1404
expect(result).not.toContain("2025"); // never the Gregorian year
});

it("formats the Jalali month name for month-only output", () => {
const result = formatDateTime(date, { locale: "fa", timeZone: "UTC", month: "long" });

expect(result).toBe("آذر");
});

it("returns Persian weekday names", () => {
expect(formatWeekday("fa", 6, "long")).toBe("شنبه"); // Saturday
});

it("orders weekday names starting on Saturday", () => {
const result = getWeekdayNames("fa", 6, "long");

expect(result).toHaveLength(7);
expect(result[0]).toBe("شنبه"); // Saturday first
expect(result[6]).toBe("جمعه"); // Friday last
});
});

describe("timezone handling", () => {
it("formats in UTC correctly for both Intl and dayjs paths", () => {
const date = new Date("2025-12-01T10:30:00Z");
Expand Down
42 changes: 34 additions & 8 deletions packages/lib/dateTimeFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ const localeSupport = new Map<string, boolean>();
const formatterCache = new Map<string, Intl.DateTimeFormat>();
const weekdayCache = new Map<string, string[]>();

/**
* Persian (fa) uses the Jalali/Shamsi calendar, which the dayjs fallback path cannot
* render. `Intl` defaults `fa` to the Persian calendar, so we always route Persian
* locales through `Intl` to avoid silently emitting Gregorian dates.
*/
export function isPersianCalendarLocale(locale: string): boolean {
const normalized = locale.toLowerCase();
return /^fa(-|$)/.test(normalized) || normalized.includes("-ca-persian");
}

/** Makes the Persian calendar explicit so behavior is stable across ICU versions. */
function resolveFormatterLocale(locale: string): string {
if (isPersianCalendarLocale(locale) && !locale.toLowerCase().includes("-ca-")) {
return `${locale}-u-ca-persian`;
}
return locale;
}
Comment on lines +40 to +46
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Intl.DateTimeFormat support the calendar option (e.g. calendar: "persian") and in which engine/Node versions?

💡 Result:

Yes, the Intl.DateTimeFormat constructor supports the calendar option [1][2][3]. You can specify a calendar, such as "persian", "chinese", or "gregory", in the options object [1][2]. This option can also be set via the Unicode extension key "ca" in the locale string (e.g., "en-u-ca-persian") [1][3]. When both are provided, the options property takes precedence [1][3]. Regarding support: - Engines: The calendar option is part of the standard ECMAScript Internationalization API (ECMA-402) and is supported in all modern web browsers and server-side JavaScript environments that include ICU (International Components for Unicode) data [1][2][4][5]. - Node.js Versions: Support for this feature in Node.js depends on the version of the ICU library bundled with the Node.js release [4][5]. Since Node.js 13+, the default builds include full ICU data, which supports these calendar types [4][5]. If you are using a custom build or an older version of Node.js, you may need to ensure full-icu or system-icu data is available to support non-Gregorian calendars; otherwise, it may fall back to default behavior [4][5]. Important Note: When using the calendar option, keep in mind that the calendar must be supported for the specified locale. If you pass an invalid or unsupported calendar, the environment typically ignores it or reverts to the locale's default calendar [6][7][8]. You can verify which calendars are supported for a given environment using Intl.supportedValuesOf('calendar') [1][3]. Additionally, third-party polyfills, such as those found in some older versions of FormatJS, might not implement full non-Gregorian calendar support [9].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and show the relevant portions of packages/lib/dateTimeFormatter.ts
FILE="packages/lib/dateTimeFormatter.ts"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE"
  exit 1
fi

# Show around the referenced lines (40-46, plus a bit more for context)
nl -ba "$FILE" | sed -n '1,120p'

Repository: calcom/cal.diy

Length of output: 102


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Additionally, quickly search for resolveFormatterLocale / isPersianCalendarLocale usage
rg -n "resolveFormatterLocale|isPersianCalendarLocale|DateTimeFormatter|calendar" packages/lib/dateTimeFormatter.ts packages/lib -S || true

Repository: calcom/cal.diy

Length of output: 48398


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="packages/lib/dateTimeFormatter.ts"
echo "== dateTimeFormatter.ts (relevant section) =="
sed -n '1,220p' "$FILE" | sed -n '20,120p'

echo
echo "== dateTimeFormatter.ts (rest around resolve/build options) =="
sed -n '100,210p' "$FILE"

TEST="packages/lib/dateTimeFormatter.test.ts"
echo
echo "== dateTimeFormatter.test.ts (Persian section) =="
# show around the Persian describe block and any tests for -u- / locale strings
sed -n '250,360p' "$TEST"

Repository: calcom/cal.diy

Length of output: 9464


🏁 Script executed:

#!/bin/bash
set -euo pipefail

node - <<'NODE'
function tryLocale(locale){
  try{
    const fmt = new Intl.DateTimeFormat(locale, { timeZone: "UTC" });
    const ro = fmt.resolvedOptions();
    // Some engines may not expose calendar; keep it best-effort
    console.log(JSON.stringify({ locale, ok: true, calendar: ro.calendar, numberingSystem: ro.numberingSystem }));
  }catch(e){
    console.log(JSON.stringify({ locale, ok: false, name: e && e.name, message: e && e.message }));
  }
}

console.log("=== duplicate -u- locale behavior ===");
tryLocale("fa");
tryLocale("fa-IR");
tryLocale("fa-u-nu-latn");
tryLocale("fa-u-nu-latn-u-ca-persian"); // what resolveFormatterLocale currently emits
tryLocale("en-US-u-ca-persian");

console.log("\n=== options.calendar precedence ===");
try{
  const fmt = new Intl.DateTimeFormat("fa-u-nu-latn", { timeZone:"UTC", calendar:"persian" });
  console.log(JSON.stringify({ localeBase:"fa-u-nu-latn", ok:true, calendar: fmt.resolvedOptions().calendar, numberingSystem: fmt.resolvedOptions().numberingSystem }));
}catch(e){
  console.log(JSON.stringify({ ok:false, name: e && e.name, message: e && e.message }));
}
NODE

Repository: calcom/cal.diy

Length of output: 672


🏁 Script executed:

#!/bin/bash
set -euo pipefail
node - <<'NODE'
function tryLocale(locale){
  try{
    const fmt = new Intl.DateTimeFormat(locale, { timeZone: "UTC" });
    const ro = fmt.resolvedOptions();
    console.log(JSON.stringify({ locale, ok:true, calendar: ro.calendar, numberingSystem: ro.numberingSystem }));
  }catch(e){
    console.log(JSON.stringify({ locale, ok:false, name: e && e.name, message: e && e.message }));
  }
}

tryLocale("fa-u-nu-latn-ca-persian");
tryLocale("fa-u-nu-latn-u-ca-persian");
NODE

Repository: calcom/cal.diy

Length of output: 281


Fix resolveFormatterLocale to avoid generating invalid BCP-47 tags (duplicate -u-)

resolveFormatterLocale appends -u-ca-persian whenever the locale is Persian per isPersianCalendarLocale and doesn’t already contain -ca-. For inputs like fa-u-nu-latn, it produces fa-u-nu-latn-u-ca-persian, which throws RangeError: Invalid language tag and breaks Intl-based formatting for that locale.

A more robust approach is to set the calendar via Intl.DateTimeFormat options (calendar: "persian") instead of mutating the locale string.

🛡️ Minimal hardening of the string approach
 function resolveFormatterLocale(locale: string): string {
-  if (isPersianCalendarLocale(locale) && !locale.toLowerCase().includes("-ca-")) {
-    return `${locale}-u-ca-persian`;
-  }
-  return locale;
+  const lower = locale.toLowerCase();
+  if (isPersianCalendarLocale(locale) && !lower.includes("-ca-")) {
+    return lower.includes("-u-") ? `${locale}-ca-persian` : `${locale}-u-ca-persian`;
+  }
+  return locale;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/lib/dateTimeFormatter.ts` around lines 40 - 46,
resolveFormatterLocale currently appends the string "-u-ca-persian" and can
produce invalid BCP-47 tags (e.g., fa-u-nu-latn-u-ca-persian); change the
implementation to stop mutating the locale string and instead ensure callers
pass { calendar: "persian" } to Intl.DateTimeFormat when
isPersianCalendarLocale(locale) returns true, so update resolveFormatterLocale
(and any callers) to return the original locale unchanged and add logic where
DateTimeFormat is constructed to include calendar: "persian" for Persian locales
(keep references to resolveFormatterLocale and isPersianCalendarLocale to locate
changes).


function isSupported(locale: string): boolean {
let supported = localeSupport.get(locale);
if (supported === undefined) {
Expand All @@ -36,6 +54,11 @@ function isSupported(locale: string): boolean {
return supported;
}

/** Persian locales are forced through Intl (Jalali-aware) instead of the Gregorian dayjs fallback. */
function shouldUseIntl(locale: string): boolean {
return isPersianCalendarLocale(locale) || isSupported(locale);
}

function getFormatter(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
const key = `${locale}|${JSON.stringify(options)}`;
let formatter = formatterCache.get(key);
Expand Down Expand Up @@ -87,22 +110,25 @@ function getDateForWeekday(day: number): Date {
}

export function formatDateTime(date: Date, options: DateTimeFormatOptions): string {
if (isSupported(options.locale)) {
return getFormatter(options.locale, buildIntlOptions(options)).format(date);
if (shouldUseIntl(options.locale)) {
return getFormatter(resolveFormatterLocale(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);
if (shouldUseIntl(options.locale)) {
return getFormatter(resolveFormatterLocale(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));
if (shouldUseIntl(locale)) {
return getFormatter(resolveFormatterLocale(locale), { weekday: format }).format(getDateForWeekday(day));
}
return dayjs().day(day).locale(locale).format(WEEKDAY_FORMATS[format]);
}
Expand All @@ -116,8 +142,8 @@ export function getWeekdayNames(locale: string, weekStart = 0, format: "short" |
weekdayCache.clear();
}

if (isSupported(locale)) {
const formatter = getFormatter(locale, { weekday: format });
if (shouldUseIntl(locale)) {
const formatter = getFormatter(resolveFormatterLocale(locale), { weekday: format });
names = Array.from({ length: 7 }, (_, i) => formatter.format(getDateForWeekday((i + weekStart) % 7)));
} else {
names = Array.from({ length: 7 }, (_, i) =>
Expand Down
16 changes: 15 additions & 1 deletion packages/lib/weekday.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";

import "@calcom/dayjs/locales";

import { nameOfDay, weekdayNames } from "./weekday";
import { getWeekStartForLocale, nameOfDay, weekdayNames } from "./weekday";

describe("Weekday tests", () => {
describe("fn: weekdayNames", () => {
Expand Down Expand Up @@ -71,4 +71,18 @@ describe("Weekday tests", () => {
expect(nameOfDay("nb", 1, "long")).toMatch(/mandag/i); // Monday in Norwegian
});
});

describe("fn: getWeekStartForLocale", () => {
it("defaults to Saturday (6) for Persian locales", () => {
expect(getWeekStartForLocale("fa")).toBe(6);
expect(getWeekStartForLocale("fa-IR")).toBe(6);
expect(getWeekStartForLocale(["fa-IR", "fa"])).toBe(6);
});

it("defaults to Sunday (0) for non-Persian locales", () => {
expect(getWeekStartForLocale("en")).toBe(0);
expect(getWeekStartForLocale("ar")).toBe(0);
expect(getWeekStartForLocale(undefined)).toBe(0);
});
});
});
14 changes: 13 additions & 1 deletion packages/lib/weekday.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { getWeekdayNames, formatWeekday } from "./dateTimeFormatter";
import { formatWeekday, getWeekdayNames, isPersianCalendarLocale } from "./dateTimeFormatter";

type WeekdayFormat = "short" | "long";

const SATURDAY = 6;
const SUNDAY = 0;

/**
* Default first day of the week for a locale when the user has not set one explicitly.
* Persian (Shamsi) calendars start the week on Saturday; everything else defaults to Sunday.
*/
export function getWeekStartForLocale(locale: string | string[] | undefined): 0 | 6 {
const normalizedLocale = Array.isArray(locale) ? locale[0] : locale || "en";
return isPersianCalendarLocale(normalizedLocale) ? SATURDAY : SUNDAY;
}
Comment on lines +12 to +15
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Consider handling empty array input defensively.

If an empty array is passed, locale[0] evaluates to undefined, which is then passed to isPersianCalendarLocale(undefined). However, when undefined is passed directly, the normalization defaults to "en". This creates inconsistent behavior between getWeekStartForLocale([]) and getWeekStartForLocale(undefined).

While empty arrays may be unlikely in practice, adding a fallback would make the function more robust and consistent.

🛡️ Proposed defensive fix
 export function getWeekStartForLocale(locale: string | string[] | undefined): 0 | 6 {
-  const normalizedLocale = Array.isArray(locale) ? locale[0] : locale || "en";
+  const normalizedLocale = Array.isArray(locale) ? (locale[0] || "en") : (locale || "en");
   return isPersianCalendarLocale(normalizedLocale) ? SATURDAY : SUNDAY;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function getWeekStartForLocale(locale: string | string[] | undefined): 0 | 6 {
const normalizedLocale = Array.isArray(locale) ? locale[0] : locale || "en";
return isPersianCalendarLocale(normalizedLocale) ? SATURDAY : SUNDAY;
}
export function getWeekStartForLocale(locale: string | string[] | undefined): 0 | 6 {
const normalizedLocale = Array.isArray(locale) ? (locale[0] || "en") : (locale || "en");
return isPersianCalendarLocale(normalizedLocale) ? SATURDAY : SUNDAY;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/lib/weekday.ts` around lines 12 - 15, getWeekStartForLocale treats
an empty array differently than undefined because Array.isArray(locale) ?
locale[0] : locale || "en" yields undefined for [], leading to inconsistent
behavior; update the normalization in getWeekStartForLocale to defensively
handle empty arrays by using the first element only if present (e.g., fallback
to "en" when locale is [] or locale[0] is undefined) before calling
isPersianCalendarLocale, so the function consistently returns SATURDAY or SUNDAY
for [], undefined, or string inputs.


export function weekdayNames(locale: string | string[], weekStart = 0, format: WeekdayFormat = "long") {
const normalizedLocale = Array.isArray(locale) ? locale[0] : locale || "en";
return getWeekdayNames(normalizedLocale, weekStart, format);
Expand Down
Loading