feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)
* feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start - Add jalaali-js library for Gregorian ↔ Jalali date conversion - Create lib/jalali-utils.ts with Jalali calendar utilities - Create hooks/use-calendar-locale.ts for unified calendar locale handling - Expand FirstDayOfWeek type to include 6 (Saturday) - Update all calendar views (month, week, day, mini, toolbar) to support Jalali calendar display and Saturday-first week ordering - Add Jalali month names (Farvardin … Esfand) to all locale files - Add Persian (fa) locale with full translations - Update settings UI to include Saturday as first day of week option - Update useFormatEventDate to show Jalali dates when locale is fa - Auto-detect Jalali calendar when fa locale is active The calendar system automatically switches to Jalali when the locale is set to Persian (fa). All internal date handling remains Gregorian (ISO 8601) for JMAP protocol compatibility; Jalali conversion is purely at the display layer. * Add PR template for Jalali calendar feature * chore: remove accidentally added PR template * fix: add image_too_large key to fa locale for PR #462 compatibility
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import {
|
||||
toJalali,
|
||||
jalaliMonthLength,
|
||||
startOfJalaliMonth,
|
||||
endOfJalaliMonth,
|
||||
eachDayOfJalaliMonth,
|
||||
getDayHeaderKeys,
|
||||
shouldUseJalaliCalendar,
|
||||
JALALI_MONTHS,
|
||||
type JalaliDate,
|
||||
} from "@/lib/jalali-utils";
|
||||
import {
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
eachDayOfInterval,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isToday,
|
||||
} from "date-fns";
|
||||
|
||||
/**
|
||||
* Unified calendar-locale hook.
|
||||
*
|
||||
* Abstracts away the differences between Gregorian and Jalali calendars so
|
||||
* view components can render dates correctly without calendar-specific
|
||||
* branching.
|
||||
*/
|
||||
export function useCalendarLocale() {
|
||||
const locale = useLocale();
|
||||
const firstDayOfWeek = useSettingsStore((s) => s.firstDayOfWeek);
|
||||
const isJalali = shouldUseJalaliCalendar(locale);
|
||||
|
||||
// Normalize weekStart for date-fns (0 | 1 | 2 | 3 | 4 | 5 | 6)
|
||||
const weekStartsOn = useMemo(() => {
|
||||
if (firstDayOfWeek === 0) return 0 as const;
|
||||
if (firstDayOfWeek === 6) return 6 as const;
|
||||
return 1 as const;
|
||||
}, [firstDayOfWeek]);
|
||||
|
||||
// Ordered day-header translation keys
|
||||
const dayHeaderKeys = useMemo(
|
||||
() => getDayHeaderKeys(weekStartsOn),
|
||||
[weekStartsOn],
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Month-grid construction
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Build the flat array of Dates that populate a full month grid. */
|
||||
const getMonthGridDays = (referenceDate: Date): Date[] => {
|
||||
if (isJalali) {
|
||||
const { jy, jm } = toJalali(referenceDate);
|
||||
return eachDayOfJalaliMonth(jy, jm, weekStartsOn);
|
||||
}
|
||||
const monthStart = startOfMonth(referenceDate);
|
||||
const monthEnd = endOfMonth(referenceDate);
|
||||
const gridStart = startOfWeek(monthStart, { weekStartsOn });
|
||||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn });
|
||||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Day-level queries
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Is the given date "today" in the active calendar system? */
|
||||
const checkIsToday = (date: Date): boolean => {
|
||||
if (isJalali) {
|
||||
const now = toJalali(new Date());
|
||||
const target = toJalali(date);
|
||||
return now.jy === target.jy && now.jm === target.jm && now.jd === target.jd;
|
||||
}
|
||||
return isToday(date);
|
||||
};
|
||||
|
||||
/** Does the date belong to the same month as the reference date? */
|
||||
const checkIsSameMonth = (date: Date, referenceDate: Date): boolean => {
|
||||
if (isJalali) {
|
||||
const a = toJalali(date);
|
||||
const b = toJalali(referenceDate);
|
||||
return a.jy === b.jy && a.jm === b.jm;
|
||||
}
|
||||
return isSameMonth(date, referenceDate);
|
||||
};
|
||||
|
||||
/** Are two dates the same calendar day? */
|
||||
const checkIsSameDay = (date1: Date, date2: Date): boolean => {
|
||||
if (isJalali) {
|
||||
const a = toJalali(date1);
|
||||
const b = toJalali(date2);
|
||||
return a.jy === b.jy && a.jm === b.jm && a.jd === b.jd;
|
||||
}
|
||||
return isSameDay(date1, date2);
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Display formatting
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Day-of-month number for a calendar cell (string). */
|
||||
const formatDayNumber = (date: Date): string => {
|
||||
if (isJalali) {
|
||||
return String(toJalali(date).jd);
|
||||
}
|
||||
return String(date.getDate());
|
||||
};
|
||||
|
||||
/** Full month + year label for the toolbar / mini-calendar header. */
|
||||
const formatMonthYear = (date: Date): string => {
|
||||
if (isJalali) {
|
||||
const { jy, jm } = toJalali(date);
|
||||
return `${JALALI_MONTHS[jm - 1]} ${jy}`;
|
||||
}
|
||||
const month = date.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
month: "long",
|
||||
});
|
||||
return `${month} ${date.getFullYear()}`;
|
||||
};
|
||||
|
||||
/** Short month + year for mobile. */
|
||||
const formatMonthYearShort = (date: Date): string => {
|
||||
if (isJalali) {
|
||||
const { jy, jm } = toJalali(date);
|
||||
const short = JALALI_MONTHS[jm - 1].slice(0, 3);
|
||||
return `${short} ${jy}`;
|
||||
}
|
||||
const month = date.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
month: "short",
|
||||
});
|
||||
return `${month} ${date.getFullYear()}`;
|
||||
};
|
||||
|
||||
/** Week range label (e.g. "6 – 12 Farvardin 1404"). */
|
||||
const formatWeekRange = (weekStart: Date): string => {
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||
if (isJalali) {
|
||||
const start = toJalali(weekStart);
|
||||
const end = toJalali(weekEnd);
|
||||
if (start.jm === end.jm) {
|
||||
return `${start.jd} – ${end.jd} ${JALALI_MONTHS[start.jm - 1]} ${start.jy}`;
|
||||
}
|
||||
return `${start.jd} ${JALALI_MONTHS[start.jm - 1]} – ${end.jd} ${JALALI_MONTHS[end.jm - 1]} ${end.jy}`;
|
||||
}
|
||||
const sameMonth = weekStart.getMonth() === weekEnd.getMonth();
|
||||
const s = weekStart.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
const e = weekEnd.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
month: sameMonth ? undefined : "short",
|
||||
day: "numeric",
|
||||
});
|
||||
return `${s} – ${e}, ${weekEnd.getFullYear()}`;
|
||||
};
|
||||
|
||||
/** Short week range for mobile. */
|
||||
const formatWeekRangeShort = (weekStart: Date): string => {
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||
if (isJalali) {
|
||||
const start = toJalali(weekStart);
|
||||
const end = toJalali(weekEnd);
|
||||
return `${start.jd}/${start.jm} – ${end.jd}/${end.jm}`;
|
||||
}
|
||||
const s = weekStart.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
const e = weekEnd.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
day: "numeric",
|
||||
});
|
||||
return `${s} – ${e}`;
|
||||
};
|
||||
|
||||
/** Full date label for accessibility / tooltips. */
|
||||
const formatFullDate = (date: Date): string => {
|
||||
if (isJalali) {
|
||||
const { jy, jm, jd } = toJalali(date);
|
||||
const dayOfWeek = date.getDay();
|
||||
const dayNames = getDayHeaderKeys(weekStartsOn);
|
||||
// Map from Gregorian day index to the correct label from the reordered list
|
||||
const dayIdx = (dayOfWeek - weekStartsOn + 7) % 7;
|
||||
const dayKey = dayNames[dayIdx];
|
||||
return `${dayKey} ${jd} ${JALALI_MONTHS[jm - 1]} ${jy}`;
|
||||
}
|
||||
return date.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Calendar-system-aware month/year getters (for navigation, etc.)
|
||||
// All return values use **0-based** months to stay compatible with
|
||||
// date-fns functions like `setMonth`.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const getMonth = (date: Date): number => {
|
||||
if (isJalali) return toJalali(date).jm - 1; // 0-11
|
||||
return date.getMonth(); // 0-11
|
||||
};
|
||||
|
||||
const getYear = (date: Date): number => {
|
||||
if (isJalali) return toJalali(date).jy;
|
||||
return date.getFullYear();
|
||||
};
|
||||
|
||||
/** Keys for the month selector dropdown (used by MiniCalendar). */
|
||||
const monthLabelKeys = useMemo(() => {
|
||||
if (isJalali) {
|
||||
return [
|
||||
"far", "ord", "kho", "tir", "mor", "sha",
|
||||
"meh", "aba", "aza", "dey", "bah", "esf",
|
||||
];
|
||||
}
|
||||
return [
|
||||
"jan", "feb", "mar", "apr", "may", "jun",
|
||||
"jul", "aug", "sep", "oct", "nov", "dec",
|
||||
];
|
||||
}, [isJalali]);
|
||||
|
||||
return {
|
||||
isJalali,
|
||||
weekStartsOn,
|
||||
dayHeaderKeys,
|
||||
getMonthGridDays,
|
||||
checkIsToday,
|
||||
checkIsSameMonth,
|
||||
checkIsSameDay,
|
||||
formatDayNumber,
|
||||
formatMonthYear,
|
||||
formatMonthYearShort,
|
||||
formatWeekRange,
|
||||
formatWeekRangeShort,
|
||||
formatFullDate,
|
||||
getMonth,
|
||||
getYear,
|
||||
monthLabelKeys,
|
||||
} as const;
|
||||
}
|
||||
@@ -1,27 +1,42 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { format } from "date-fns";
|
||||
import { toJalali, shouldUseJalaliCalendar, JALALI_MONTHS } from "@/lib/jalali-utils";
|
||||
|
||||
/**
|
||||
* Returns a memoized function that formats a calendar event date
|
||||
* using the current locale for day and month names.
|
||||
*
|
||||
*
|
||||
* The string will be in the format: "EEE, MMM d, yyyy"
|
||||
*
|
||||
*
|
||||
* For example: "Wed, Apr 29, 2026" (en)
|
||||
* "Qua, Abr 29, 2026" (pt)
|
||||
*
|
||||
* When the Jalali calendar is active (fa locale), the format uses
|
||||
* Persian day/month names with the Jalali year, e.g.:
|
||||
* "چهارشنبه, ۹ اردیبهشت ۱۴۰۵"
|
||||
*/
|
||||
export function useFormatEventDate(): (date: Date) => string {
|
||||
const t = useTranslations("calendar");
|
||||
const locale = useLocale();
|
||||
const isJalali = shouldUseJalaliCalendar(locale);
|
||||
|
||||
return useCallback(
|
||||
(date: Date): string => {
|
||||
if (isJalali) {
|
||||
const { jy, jm, jd } = toJalali(date);
|
||||
// Use Gregorian day-of-week for the translation key (date-fns format)
|
||||
const dayOfWeek = format(date, "EEE").toLowerCase();
|
||||
const monthName = JALALI_MONTHS[jm - 1];
|
||||
return `${t(`days.${dayOfWeek}`)}, ${jd} ${monthName} ${jy}`;
|
||||
}
|
||||
|
||||
const dayOfWeek = format(date, "EEE").toLowerCase();
|
||||
const month = format(date, "MMM").toLowerCase();
|
||||
const day = format(date, "d");
|
||||
const year = format(date, "yyyy");
|
||||
return `${t(`days.${dayOfWeek}`)}, ${t(`months.${month}`)} ${day}, ${year}`;
|
||||
},
|
||||
[t]
|
||||
[t, isJalali]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user