diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 0b230512..c91a71bd 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -1,11 +1,8 @@ "use client"; import { useMemo, useState, useCallback, type DragEvent } from "react"; -import { useTranslations, useFormatter } from "next-intl"; -import { - startOfMonth, endOfMonth, startOfWeek, endOfWeek, - eachDayOfInterval, isSameDay, isSameMonth, isToday, format, parseISO, -} from "date-fns"; +import { useTranslations } from "next-intl"; +import { format, parseISO } from "date-fns"; import { cn } from "@/lib/utils"; import { EventCard } from "./event-card"; import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils"; @@ -14,6 +11,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useCalendarStore } from "@/stores/calendar-store"; import type { PendingEventPreview } from "./event-modal"; import { toast } from "@/stores/toast-store"; +import { useCalendarLocale } from "@/hooks/use-calendar-locale"; interface CalendarMonthViewProps { selectedDate: Date; @@ -47,16 +45,21 @@ export function CalendarMonthView({ pendingPreview, }: CalendarMonthViewProps) { const t = useTranslations("calendar"); - const intlFormatter = useFormatter(); - const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; + const { + weekStartsOn, + dayHeaderKeys, + getMonthGridDays, + checkIsToday, + checkIsSameMonth, + checkIsSameDay, + formatDayNumber, + formatFullDate, + } = useCalendarLocale(); - const days = useMemo(() => { - const monthStart = startOfMonth(selectedDate); - const monthEnd = endOfMonth(selectedDate); - const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart }); - const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart }); - return eachDayOfInterval({ start: gridStart, end: gridEnd }); - }, [selectedDate, weekStart]); + const days = useMemo( + () => getMonthGridDays(selectedDate), + [selectedDate, getMonthGridDays], + ); const calendarMap = useMemo(() => { const map = new Map(); @@ -83,10 +86,6 @@ export function CalendarMonthView({ return map; }, [events]); - const dayHeaders = firstDayOfWeek === 0 - ? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const - : ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const; - const weeks = useMemo(() => { const result: Date[][] = []; for (let i = 0; i < days.length; i += 7) { @@ -141,9 +140,9 @@ export function CalendarMonthView({ }, [t]); return ( -
+
- {dayHeaders.map((d) => ( + {dayHeaderKeys.map((d) => (
{week.map((day) => { - const inMonth = isSameMonth(day, selectedDate); - const selected = isSameDay(day, selectedDate); - const today = isToday(day); + const inMonth = checkIsSameMonth(day, selectedDate); + const selected = checkIsSameDay(day, selectedDate); + const today = checkIsToday(day); const key = format(day, "yyyy-MM-dd"); const dayEvents = eventsByDate.get(key) || []; - const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); + const fullDateLabel = formatFullDate(day); return (
- {format(day, "d")} + {formatDayNumber(day)}
{isMobile ? ( @@ -219,7 +218,7 @@ export function CalendarMonthView({ {dayEvents.length > 3 && ( )} - {pendingPreview && isSameDay(pendingPreview.start, day) && ( + {pendingPreview && checkIsSameDay(pendingPreview.start, day) && ( {!isMobile && pendingPreview && (() => { - const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start)); + const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start)); if (previewDayIdx === -1) return null; const previewRow = rowCount; const cal = calendarMap.get(pendingPreview.calendarId); diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index ec9480dd..0a129663 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -1,13 +1,14 @@ "use client"; import { useState, useRef, useEffect } from "react"; -import { useTranslations, useFormatter } from "next-intl"; +import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react"; -import { addDays, startOfWeek } from "date-fns"; +import { startOfWeek } from "date-fns"; import { cn } from "@/lib/utils"; import type { CalendarViewMode } from "@/stores/calendar-store"; import type { Calendar } from "@/lib/jmap/types"; +import { useCalendarLocale } from "@/hooks/use-calendar-locale"; interface CalendarToolbarProps { selectedDate: Date; @@ -50,7 +51,14 @@ export function CalendarToolbar({ onMenuClick, }: CalendarToolbarProps) { const t = useTranslations("calendar"); - const formatter = useFormatter(); + const { + weekStartsOn, + formatMonthYear, + formatMonthYearShort, + formatWeekRange, + formatWeekRangeShort, + formatFullDate, + } = useCalendarLocale(); const views: CalendarViewMode[] = enableCalendarTasks ? ["month", "week", "day", "agenda", "tasks"] : ["month", "week", "day", "agenda"]; @@ -72,28 +80,22 @@ export function CalendarToolbar({ switch (viewMode) { case "month": return isMobile - ? formatter.dateTime(selectedDate, { month: "short", year: "numeric" }) - : formatter.dateTime(selectedDate, { month: "long", year: "numeric" }); + ? formatMonthYearShort(selectedDate) + : formatMonthYear(selectedDate); case "week": { - const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 }); - const we = addDays(ws, 6); - if (isMobile) { - return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}`; - } - const sameMonth = ws.getMonth() === we.getMonth(); - if (sameMonth) { - return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`; - } - return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`; + const ws = startOfWeek(selectedDate, { weekStartsOn }); + return isMobile + ? formatWeekRangeShort(ws) + : formatWeekRange(ws); } case "day": return isMobile - ? formatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" }) - : formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); + ? formatFullDate(selectedDate) + : formatFullDate(selectedDate); case "agenda": return isMobile - ? formatter.dateTime(selectedDate, { month: "short", year: "numeric" }) - : formatter.dateTime(selectedDate, { month: "long", year: "numeric" }); + ? formatMonthYearShort(selectedDate) + : formatMonthYear(selectedDate); case "tasks": return t("views.tasks"); } diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 4d4a4052..dbfe08ce 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -58,7 +58,7 @@ export function CalendarWeekView({ const intlFormatter = useFormatter(); const scrollRef = useRef(null); const rootRef = useRef(null); - const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; + const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6; const weekDays = useMemo(() => { const start = startOfWeek(selectedDate, { weekStartsOn: weekStart }); diff --git a/components/calendar/mini-calendar.tsx b/components/calendar/mini-calendar.tsx index fa602afa..a51f7420 100644 --- a/components/calendar/mini-calendar.tsx +++ b/components/calendar/mini-calendar.tsx @@ -1,25 +1,19 @@ "use client"; import { useState, useMemo, Fragment } from "react"; -import { useTranslations, useFormatter } from "next-intl"; +import { useTranslations } from "next-intl"; import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react"; import { - startOfMonth, endOfMonth, startOfWeek, endOfWeek, addMonths, subMonths, addYears, subYears, setMonth, setYear, - eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek, - isSameDay, isSameMonth, isToday, format, + getISOWeek, getWeek, format, } from "date-fns"; import { cn } from "@/lib/utils"; import { getEventDayBounds } from "@/lib/calendar-utils"; import type { CalendarEvent } from "@/lib/jmap/types"; +import { useCalendarLocale } from "@/hooks/use-calendar-locale"; type PickerView = "days" | "months" | "years"; -const MONTH_LABELS = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", -]; - interface MiniCalendarProps { selectedDate: Date; displayMonth: Date; @@ -40,17 +34,25 @@ export function MiniCalendar({ showWeekNumbers = false, }: MiniCalendarProps) { const t = useTranslations("calendar"); - const intlFormatter = useFormatter(); - const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; + const { + weekStartsOn, + dayHeaderKeys, + getMonthGridDays, + checkIsToday, + checkIsSameMonth, + checkIsSameDay, + formatDayNumber, + formatMonthYear, + getMonth, + getYear, + monthLabelKeys, + } = useCalendarLocale(); const [pickerView, setPickerView] = useState("days"); - const days = useMemo(() => { - const monthStart = startOfMonth(displayMonth); - const monthEnd = endOfMonth(displayMonth); - const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart }); - const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart }); - return eachDayOfInterval({ start: gridStart, end: gridEnd }); - }, [displayMonth, weekStart]); + const days = useMemo( + () => getMonthGridDays(displayMonth), + [displayMonth, getMonthGridDays], + ); const eventDates = useMemo(() => { const set = new Set(); @@ -67,20 +69,16 @@ export function MiniCalendar({ return set; }, [events]); - const dayHeaders = firstDayOfWeek === 0 - ? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const - : ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const; - // Compute week numbers for each row (one per 7-day chunk) const weekNumbers = useMemo(() => { if (!showWeekNumbers) return []; const nums: number[] = []; for (let i = 0; i < days.length; i += 7) { // Use the first day of each row to determine the week number - nums.push(weekStart === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 })); + nums.push(weekStartsOn === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 })); } return nums; - }, [days, showWeekNumbers, weekStart]); + }, [days, showWeekNumbers, weekStartsOn]); const currentYear = getYear(displayMonth); const currentMonth = getMonth(displayMonth); @@ -116,7 +114,7 @@ export function MiniCalendar({ const headerLabel = pickerView === "days" - ? intlFormatter.dateTime(displayMonth, { month: "long", year: "numeric" }) + ? formatMonthYear(displayMonth) : pickerView === "months" ? String(currentYear) : `${decadeStart}\u2013${decadeStart + 9}`; @@ -160,15 +158,15 @@ export function MiniCalendar({ {showWeekNumbers && (
)} - {dayHeaders.map((d) => ( + {dayHeaderKeys.map((d) => (
{t(`days.${d}`)}
))} {days.map((day, index) => { - const inMonth = isSameMonth(day, displayMonth); - const selected = isSameDay(day, selectedDate); - const today = isToday(day); + const inMonth = checkIsSameMonth(day, displayMonth); + const selected = checkIsSameDay(day, selectedDate); + const today = checkIsToday(day); const hasEvent = eventDates.has(format(day, "yyyy-MM-dd")); const isFirstDayOfRow = index % 7 === 0; @@ -193,7 +191,7 @@ export function MiniCalendar({ selected && "bg-primary text-primary-foreground" )} > - {format(day, "d")} + {formatDayNumber(day)} {hasEvent && !selected && ( )} @@ -206,7 +204,7 @@ export function MiniCalendar({ {pickerView === "months" && (
- {MONTH_LABELS.map((label, i) => { + {monthLabelKeys.map((labelKey, i) => { const isCurrentMonth = i === currentMonth && currentYear === getYear(new Date()); const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate); return ( @@ -220,7 +218,7 @@ export function MiniCalendar({ !isSelected && !isCurrentMonth && "hover:bg-muted" )} > - {label} + {t(`months.${labelKey}`)} ); })} diff --git a/components/settings/language-settings.tsx b/components/settings/language-settings.tsx index ab678c66..fc46e523 100644 --- a/components/settings/language-settings.tsx +++ b/components/settings/language-settings.tsx @@ -104,6 +104,7 @@ export function LanguageSettings() { onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as FirstDayOfWeek)} options={[ { value: '1', label: tDays('monday') }, + { value: '6', label: tDays('saturday') }, { value: '0', label: tDays('sunday') }, ]} /> diff --git a/hooks/use-calendar-locale.ts b/hooks/use-calendar-locale.ts new file mode 100644 index 00000000..d0ed1002 --- /dev/null +++ b/hooks/use-calendar-locale.ts @@ -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; +} diff --git a/hooks/use-format-event-date.ts b/hooks/use-format-event-date.ts index 137f1b39..9e09b3ee 100644 --- a/hooks/use-format-event-date.ts +++ b/hooks/use-format-event-date.ts @@ -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] ); } diff --git a/lib/jalali-utils.ts b/lib/jalali-utils.ts new file mode 100644 index 00000000..1670e37e --- /dev/null +++ b/lib/jalali-utils.ts @@ -0,0 +1,169 @@ +/** + * Jalali (Persian/Shamsi) calendar utilities. + * + * All internal date handling remains Gregorian (ISO 8601). The functions + * in this module convert Gregorian ↔ Jalali at the display layer only. + * + * Uses `jalaali-js` for the underlying calendar math. + */ +import * as jalaali from 'jalaali-js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A Jalali date represented as year, month (1-12), day (1-31). */ +export interface JalaliDate { + /** Jalali year (e.g. 1405) */ + jy: number; + /** Jalali month (1 = Farvardin, 12 = Esfand) */ + jm: number; + /** Jalali day of month (1-31) */ + jd: number; +} + +// --------------------------------------------------------------------------- +// Gregorian ↔ Jalali conversion +// --------------------------------------------------------------------------- + +/** Convert a Gregorian Date to its Jalali equivalent. */ +export function toJalali(date: Date): JalaliDate { + const { jy, jm, jd } = jalaali.toJalaali(date); + return { jy, jm, jd }; +} + +/** Convert a Jalali date to a Gregorian Date. */ +export function toGregorian(jy: number, jm: number, jd: number): Date { + const { gy, gm, gd } = jalaali.toGregorian(jy, jm, jd); + return new Date(gy, gm - 1, gd); +} + +// --------------------------------------------------------------------------- +// Jalali month / day info +// --------------------------------------------------------------------------- + +/** Full Persian month names (Farvardin … Esfand). */ +export const JALALI_MONTHS: readonly string[] = [ + 'فروردین', + 'اردیبهشت', + 'خرداد', + 'تیر', + 'مرداد', + 'شهریور', + 'مهر', + 'آبان', + 'آذر', + 'دی', + 'بهمن', + 'اسفند', +]; + +/** Number of days in a Jalali month (handles leap years). */ +export function jalaliMonthLength(jy: number, jm: number): number { + return jalaali.jalaaliMonthLength(jy, jm); +} + +/** Is the given Jalali year a leap year? */ +export function isJalaliLeapYear(jy: number): boolean { + return jalaali.isLeapJalaaliYear(jy); +} + +// --------------------------------------------------------------------------- +// Calendar grid helpers (analogous to date-fns startOfWeek / eachDayOfInterval) +// --------------------------------------------------------------------------- + +/** + * Return the first day of the Jalali month (Gregorian Date) aligned to the + * week grid so the month view can be rendered. `weekStartsOn` follows the + * same convention as `date-fns`: 0=Sun, 1=Mon, …, 6=Sat. + */ +export function startOfJalaliMonth( + jy: number, + jm: number, + weekStartsOn: number = 6, +): Date { + const firstDay = toGregorian(jy, jm, 1); + const dayOfWeek = firstDay.getDay(); // 0=Sun … 6=Sat + const offset = (dayOfWeek - weekStartsOn + 7) % 7; + const result = new Date(firstDay); + result.setDate(result.getDate() - offset); + return result; +} + +/** + * Return the last day of the Jalali month (Gregorian Date) aligned to the + * week grid. + */ +export function endOfJalaliMonth( + jy: number, + jm: number, + weekStartsOn: number = 6, +): Date { + const lastDay = toGregorian(jy, jm, jalaliMonthLength(jy, jm)); + const dayOfWeek = lastDay.getDay(); + const offset = (weekStartsOn - dayOfWeek + 6) % 7; + const result = new Date(lastDay); + result.setDate(result.getDate() + offset); + return result; +} + +/** + * Build a flat array of Gregorian Dates covering the entire calendar grid + * for a Jalali month (from the week-aligned start to the week-aligned end). + */ +export function eachDayOfJalaliMonth( + jy: number, + jm: number, + weekStartsOn: number = 6, +): Date[] { + const start = startOfJalaliMonth(jy, jm, weekStartsOn); + const end = endOfJalaliMonth(jy, jm, weekStartsOn); + const days: Date[] = []; + const cursor = new Date(start); + while (cursor <= end) { + days.push(new Date(cursor)); + cursor.setDate(cursor.getDate() + 1); + } + return days; +} + +// --------------------------------------------------------------------------- +// Locale-aware day header order +// --------------------------------------------------------------------------- + +/** + * Return the array of day-abbreviation translation keys in the correct order + * for the given `firstDayOfWeek` (0=Sun … 6=Sat). + * + * Usage: + * const dayHeaders = getDayHeaderKeys(firstDayOfWeek); + * dayHeaders.map((key) => t(`calendar.days.${key}`)) + */ +export function getDayHeaderKeys( + firstDayOfWeek: number, +): readonly string[] { + const ALL: readonly string[] = [ + 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', + ] as const; + return [...ALL.slice(firstDayOfWeek), ...ALL.slice(0, firstDayOfWeek)]; +} + +// --------------------------------------------------------------------------- +// Locale detection helper +// --------------------------------------------------------------------------- + +/** + * Should the UI render dates using the Jalali calendar? + * + * Currently this is keyed off the `fa` locale. Administrators who want a + * different locale with Jalali dates can extend this logic later. + */ +export function shouldUseJalaliCalendar(locale: string): boolean { + return locale === 'fa'; +} + +/** Default `firstDayOfWeek` for a given locale. */ +export function defaultFirstDayOfWeek(locale: string): number { + if (locale === 'fa') return 6; // Saturday + return 1; // Monday (ISO convention) +} diff --git a/locales/cs/common.json b/locales/cs/common.json index f9eb7d1a..86eb234a 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -2904,7 +2904,19 @@ "sep": "zář", "oct": "říj", "nov": "lis", - "dec": "pro" + "dec": "pro", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "nav_open_menu": "Otevřít nabídku" }, diff --git a/locales/da/common.json b/locales/da/common.json index 0a7379f0..334b635c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -2723,7 +2723,19 @@ "sep": "Sep", "oct": "Okt", "nov": "Nov", - "dec": "Dec" + "dec": "Dec", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "notifications": { "event_created": "Begivenhed oprettet", diff --git a/locales/de/common.json b/locales/de/common.json index 62dccaf5..02b42581 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2904,7 +2904,19 @@ "sep": "Sep", "oct": "Okt", "nov": "Nov", - "dec": "Dez" + "dec": "Dez", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "nav_open_menu": "Menü öffnen" }, diff --git a/locales/en/common.json b/locales/en/common.json index 5518786d..9c27f008 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2723,7 +2723,19 @@ "sep": "Sep", "oct": "Oct", "nov": "Nov", - "dec": "Dec" + "dec": "Dec", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "notifications": { "event_created": "Event created", diff --git a/locales/es/common.json b/locales/es/common.json index ce98a1d3..7110b9be 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -2904,7 +2904,19 @@ "sep": "Sep", "oct": "Oct", "nov": "Nov", - "dec": "Dic" + "dec": "Dic", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "nav_open_menu": "Abrir menú" }, diff --git a/locales/fa/common.json b/locales/fa/common.json index c14293e1..b0424395 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -681,7 +681,12 @@ "recipient_email_placeholder": "آدرس ایمیل", "recipient_name_placeholder": "نام نمایشی", "autocomplete_search_server": "جستجو در سرور", - "autocomplete_searching": "در حال جستجو..." + "autocomplete_searching": "در حال جستجو...", + "text_direction": { + "toggle": "تغییر جهت متن (چپ‌به‌راست / راست‌به‌چپ)", + "ltr": "چپ‌به‌راست", + "rtl": "راست‌به‌چپ" + } }, "confirm_dialog": { "confirm": "تأیید", @@ -1880,7 +1885,8 @@ "validation": { "empty": "نام قالب الزامی است", "too_long": "نام قالب باید ۲۰۰ کاراکتر یا کمتر باشد" - } + }, + "image_too_large": "حجم تصویر بیش از حد مجاز (حداکثر ۱ مگابایت)" }, "files": { "display": { @@ -2723,7 +2729,19 @@ "sep": "سپتامبر", "oct": "اکتبر", "nov": "نوامبر", - "dec": "دسامبر" + "dec": "دسامبر", + "far": "فروردین", + "ord": "اردیبهشت", + "kho": "خرداد", + "tir": "تیر", + "mor": "مرداد", + "sha": "شهریور", + "meh": "مهر", + "aba": "آبان", + "aza": "آذر", + "dey": "دی", + "bah": "بهمن", + "esf": "اسفند" }, "notifications": { "event_created": "رویداد ایجاد شد", diff --git a/locales/fr/common.json b/locales/fr/common.json index 3ab055de..9066c9d5 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2723,7 +2723,19 @@ "sep": "sept.", "oct": "oct.", "nov": "nov.", - "dec": "déc." + "dec": "déc.", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "notifications": { "event_created": "Événement créé", diff --git a/locales/hu/common.json b/locales/hu/common.json index 7caee657..261eeb3f 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -2723,7 +2723,19 @@ "sep": "Szep", "oct": "Okt", "nov": "Nov", - "dec": "Dec" + "dec": "Dec", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "notifications": { "event_created": "Esemény létrehozva", diff --git a/locales/it/common.json b/locales/it/common.json index 4e285a14..d288518b 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -2904,7 +2904,19 @@ "sep": "set", "oct": "ott", "nov": "nov", - "dec": "dic" + "dec": "dic", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "nav_open_menu": "Apri menu" }, diff --git a/locales/ja/common.json b/locales/ja/common.json index 28c6af0e..97bcec1f 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2904,7 +2904,19 @@ "sep": "9月", "oct": "10月", "nov": "11月", - "dec": "12月" + "dec": "12月", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "nav_open_menu": "メニューを開く" }, diff --git a/locales/ko/common.json b/locales/ko/common.json index 1e9f20b9..55d2852c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2904,7 +2904,19 @@ "sep": "9월", "oct": "10월", "nov": "11월", - "dec": "12월" + "dec": "12월", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "nav_open_menu": "메뉴 열기" }, diff --git a/locales/lv/common.json b/locales/lv/common.json index f61bb900..f9fc75b7 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -2904,7 +2904,19 @@ "sep": "sept.", "oct": "okt.", "nov": "nov.", - "dec": "dec." + "dec": "dec.", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "nav_open_menu": "Atvērt izvēlni" }, diff --git a/locales/nl/common.json b/locales/nl/common.json index f47da90a..52624f8b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2904,7 +2904,19 @@ "sep": "sep", "oct": "okt", "nov": "nov", - "dec": "dec" + "dec": "dec", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "nav_open_menu": "Menu openen" }, diff --git a/locales/pl/common.json b/locales/pl/common.json index 5f28dd90..c139a7c6 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -2904,7 +2904,19 @@ "sep": "wrz", "oct": "paź", "nov": "lis", - "dec": "gru" + "dec": "gru", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "nav_open_menu": "Otwórz menu" }, diff --git a/locales/pt/common.json b/locales/pt/common.json index 2cfc6869..c11eced3 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -2723,7 +2723,19 @@ "sep": "set", "oct": "out", "nov": "nov", - "dec": "dez" + "dec": "dez", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "notifications": { "event_created": "Evento criado", diff --git a/locales/ro/common.json b/locales/ro/common.json index 2fafc8df..ef5fed7d 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -2723,7 +2723,19 @@ "sep": "Sep", "oct": "Oct", "nov": "Nov", - "dec": "Dec" + "dec": "Dec", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "notifications": { "event_created": "Eveniment creat", diff --git a/locales/ru/common.json b/locales/ru/common.json index 9aa419f8..02d6ce9b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2904,7 +2904,19 @@ "sep": "сент.", "oct": "окт.", "nov": "нояб.", - "dec": "дек." + "dec": "дек.", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "nav_open_menu": "Открыть меню" }, diff --git a/locales/tr/common.json b/locales/tr/common.json index bdd156b9..79f886a8 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -2723,7 +2723,19 @@ "sep": "Eyl", "oct": "Eki", "nov": "Kas", - "dec": "Ara" + "dec": "Ara", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "notifications": { "event_created": "Etkinlik oluşturuldu", diff --git a/locales/uk/common.json b/locales/uk/common.json index 3e984aca..a4abe245 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2904,7 +2904,19 @@ "sep": "верес.", "oct": "жовт.", "nov": "лист.", - "dec": "груд." + "dec": "груд.", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "nav_open_menu": "Відкрити меню" }, diff --git a/locales/zh/common.json b/locales/zh/common.json index e54cae25..5e151add 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2904,7 +2904,19 @@ "sep": "9月", "oct": "10月", "nov": "11月", - "dec": "12月" + "dec": "12月", + "far": "Farvardin", + "ord": "Ordibehesht", + "kho": "Khordad", + "tir": "Tir", + "mor": "Mordad", + "sha": "Shahrivar", + "meh": "Mehr", + "aba": "Aban", + "aza": "Azar", + "dey": "Dey", + "bah": "Bahman", + "esf": "Esfand" }, "nav_open_menu": "打开菜单" }, diff --git a/package-lock.json b/package-lock.json index ed51ec03..0763801f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "dompurify": "^3.4.1", + "jalaali-js": "^2.0.0", "jszip": "^3.10.1", "lucide-react": "^1.8.0", "next": "^16.2.6", @@ -6222,7 +6223,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -7139,6 +7139,15 @@ "node": ">= 0.4" } }, + "node_modules/jalaali-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-2.0.0.tgz", + "integrity": "sha512-HkWlwO3KxuYwERP1jsn+5M+QA+EKpIJ+zGesLee5VJNn2d2Melue0uQIvd9C/0QYFR7XVWvfF/uiNEz9Jbr9Hw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -7894,17 +7903,6 @@ } } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.23", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", - "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", diff --git a/package.json b/package.json index dc954f4c..baf46cf0 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "dompurify": "^3.4.1", + "jalaali-js": "^2.0.0", "jszip": "^3.10.1", "lucide-react": "^1.8.0", "next": "^16.2.6", diff --git a/stores/settings-store.ts b/stores/settings-store.ts index e958f17b..abb4b324 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -48,7 +48,7 @@ export type DateFormat = 'smart' | 'relative' | 'full'; */ export type DateLocale = 'auto' | 'iso' | 'en-GB' | 'en-US'; export type TimeFormat = '12h' | '24h'; -export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday +export type FirstDayOfWeek = 0 | 1 | 6; // 0 = Sunday, 1 = Monday, 6 = Saturday export type ExternalContentPolicy = 'ask' | 'block' | 'allow'; export type MailAttachmentAction = 'preview' | 'download'; export type AttachmentPosition = 'beside-sender' | 'below-header';