From f1297b6be8e2677ab834cf80be986e4eb9e7e766 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:45:24 +0100 Subject: [PATCH] feat: show calendar months, days and dates in the Jalali calendar for Persian --- src/components/calendar/AgendaView.tsx | 26 +-- .../calendar/CalendarSearchSheet.tsx | 7 +- src/components/calendar/EventDetailSheet.tsx | 10 +- src/components/calendar/EventModal.tsx | 6 +- src/components/calendar/MonthDropdown.tsx | 10 +- src/components/calendar/MonthScrollView.tsx | 23 +- src/components/calendar/MonthView.tsx | 39 ++-- src/components/calendar/RecurrenceEditor.tsx | 7 +- src/components/calendar/TasksSheet.tsx | 8 +- .../calendar/TimeGridScrollView.tsx | 4 +- src/components/calendar/WeekView.tsx | 5 +- src/lib/__tests__/calendar-system.test.ts | 153 ++++++++++++++ src/lib/calendar-a11y.ts | 10 +- src/lib/calendar-locale.ts | 10 +- src/lib/calendar-month-scroll.ts | 11 +- src/lib/calendar-scroll-window.ts | 4 + src/lib/calendar-system.ts | 198 ++++++++++++++++++ src/lib/jalali.ts | 137 ++++++++++++ src/screens/CalendarScreen.tsx | 33 ++- 19 files changed, 606 insertions(+), 95 deletions(-) create mode 100644 src/lib/__tests__/calendar-system.test.ts create mode 100644 src/lib/calendar-system.ts create mode 100644 src/lib/jalali.ts diff --git a/src/components/calendar/AgendaView.tsx b/src/components/calendar/AgendaView.tsx index 937c6a10..b5072c7f 100644 --- a/src/components/calendar/AgendaView.tsx +++ b/src/components/calendar/AgendaView.tsx @@ -23,6 +23,7 @@ import { import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window'; import { buildAgendaDays, findAgendaFocusIndex, type AgendaDay } from '../../lib/calendar-agenda'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { dayNumberLabel, dayRangeLabel, fullDateLabel, monthTitle, type CalendarSystem } from '../../lib/calendar-system'; import { useLocaleStore } from '../../stores/locale-store'; import { EventCard } from './EventCard'; @@ -63,12 +64,12 @@ interface DaySection extends AgendaDay { weekTitle: string | null; } -// The week separators: "Oct 11 – 17", or "Sep 27 – Oct 3" across months. -function formatWeekRange(date: Date, weekStartsOn: 0 | 1 | 6, locale: Locale): string { +// The week separators: "Oct 11 – 17", or "Sep 27 – Oct 3" across months +// (Jalali days and months in Persian). +function formatWeekRange(date: Date, weekStartsOn: 0 | 1 | 6, locale: Locale, system: CalendarSystem): string { const start = startOfWeek(date, { weekStartsOn }); const end = endOfWeek(date, { weekStartsOn }); - const sameMonth = start.getMonth() === end.getMonth(); - return `${format(start, 'MMM d', { locale })} – ${format(end, sameMonth ? 'd' : 'MMM d', { locale })}`; + return dayRangeLabel(start, end, system, locale); } const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 1 }; @@ -102,7 +103,7 @@ export function AgendaView({ }: AgendaViewProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - const { locale, t } = useCalendarLocale(); + const { locale, t, system } = useCalendarLocale(); const tp = useLocaleStore((s) => s.t); const index = React.useMemo( () => eventsByDay ?? buildEventDayIndex(events), @@ -115,15 +116,15 @@ export function AgendaView({ let lastMonth = ''; let lastWeek = ''; return buildAgendaDays(index, loaded, displayNow()).map((day) => { - const month = format(day.date, 'yyyy-MM'); + const month = String(system.monthKey(day.date)); const week = dayKey(startOfWeek(day.date, { weekStartsOn })); - const monthTitle = month !== lastMonth ? format(day.date, 'LLLL yyyy', { locale }) : null; - const weekTitle = week !== lastWeek ? formatWeekRange(day.date, weekStartsOn, locale) : null; + const title = month !== lastMonth ? monthTitle(day.date, system, locale, true) : null; + const weekTitle = week !== lastWeek ? formatWeekRange(day.date, weekStartsOn, locale, system) : null; lastMonth = month; lastWeek = week; - return { ...day, key: dayKey(day.date), monthTitle, weekTitle }; + return { ...day, key: dayKey(day.date), monthTitle: title, weekTitle }; }); - }, [index, loaded, weekStartsOn, locale]); + }, [index, loaded, weekStartsOn, locale, system]); const listRef = React.useRef>(null); const sectionsRef = React.useRef(sections); @@ -245,7 +246,7 @@ export function AgendaView({ sizeRef.current.content = h; }, []); - const formatRangeDate = (date: Date) => format(date, 'PP', { locale }); + const formatRangeDate = (date: Date) => (system.jalali ? fullDateLabel(date, system, locale) : format(date, 'PP', { locale })); const header = ( @@ -383,6 +384,7 @@ function DateColumn({ locale: Locale; styles: AgendaStyles; }) { + const { system } = useCalendarLocale(); if (!show) return ; const today = isDisplayToday(date); return ( @@ -392,7 +394,7 @@ function DateColumn({ - {format(date, 'd')} + {dayNumberLabel(date, system)} diff --git a/src/components/calendar/CalendarSearchSheet.tsx b/src/components/calendar/CalendarSearchSheet.tsx index 67671056..c2a7e085 100644 --- a/src/components/calendar/CalendarSearchSheet.tsx +++ b/src/components/calendar/CalendarSearchSheet.tsx @@ -17,6 +17,7 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { useCalendarStore, searchCalendarEvents } from '../../stores/calendar-store'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { formatCalendarDay } from '../../lib/calendar-system'; import { displayNow } from '../../lib/calendar-timezone'; import { eventMatches, orderSearchResults, searchTerms } from '../../lib/calendar-search'; import { @@ -62,7 +63,7 @@ export function CalendarSearchSheet({ }: CalendarSearchSheetProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - const { locale, t } = useCalendarLocale(); + const { locale, t, system } = useCalendarLocale(); const loaded = useCalendarStore((s) => s.events); const [query, setQuery] = React.useState(''); const [remote, setRemote] = React.useState<{ query: string; events: CalendarEvent[] } | null>(null); @@ -149,7 +150,9 @@ export function CalendarSearchSheet({ > - {format(start, start.getFullYear() === thisYear ? 'EEE, MMM d' : 'MMM d, yyyy', { locale })} + {system.jalali + ? formatCalendarDay(start, locale, system.year(start) === system.year(new Date()) ? 'weekdayMonthDay' : 'monthDayYear') + : format(start, start.getFullYear() === thisYear ? 'EEE, MMM d' : 'MMM d, yyyy', { locale })} {event.showWithoutTime diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index a03ab3a8..3a52bcc1 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -57,7 +57,7 @@ import { import { getEventEditability } from '../../lib/calendar-editability'; import { useContactNameResolver } from '../../lib/contact-name-resolver'; import { useCalendarLocale } from '../../lib/calendar-locale'; -import { formatCalendarDate } from '../../lib/date-format'; +import { formatCalendarDay } from '../../lib/calendar-system'; import { useSheetDrag } from '../../lib/use-sheet-drag'; import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrence'; import type { TranslateFn } from '../../stores/locale-store'; @@ -99,9 +99,9 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l start.getMonth() === end.getMonth() && start.getDate() === end.getDate() ) { - return formatCalendarDate(start, locale.code, 'longWeekdayMonthDayYear'); + return formatCalendarDay(start, locale, 'longWeekdayMonthDayYear'); } - return `${formatCalendarDate(start, locale.code, 'monthDay')} – ${formatCalendarDate(end, locale.code, 'monthDayYear')}`; + return `${formatCalendarDay(start, locale, 'monthDay')} – ${formatCalendarDay(end, locale, 'monthDayYear')}`; } const tp = timePattern(timeFormat); const sameDay = @@ -109,9 +109,9 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l start.getMonth() === end.getMonth() && start.getDate() === end.getDate(); if (sameDay) { - return `${formatCalendarDate(start, locale.code, 'weekdayMonthDay')} · ${format(start, tp, opts)} – ${format(end, tp, opts)}`; + return `${formatCalendarDay(start, locale, 'weekdayMonthDay')} · ${format(start, tp, opts)} – ${format(end, tp, opts)}`; } - return `${formatCalendarDate(start, locale.code, 'monthDay')}, ${format(start, tp, opts)} – ${formatCalendarDate(end, locale.code, 'monthDay')}, ${format(end, tp, opts)}`; + return `${formatCalendarDay(start, locale, 'monthDay')}, ${format(start, tp, opts)} – ${formatCalendarDay(end, locale, 'monthDay')}, ${format(end, tp, opts)}`; } function recurrenceLabel(event: CalendarEvent, t: TranslateFn, locale: Locale): string | null { diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 15587b71..7c45d440 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -48,7 +48,7 @@ import { useSettingsStore } from '../../stores/settings-store'; import { CalendarAccountChangedError } from '../../stores/calendar-store'; import { canCreateEventsIn } from '../../lib/calendar-editability'; import { useCalendarLocale } from '../../lib/calendar-locale'; -import { formatCalendarDate } from '../../lib/date-format'; +import { formatCalendarDay } from '../../lib/calendar-system'; import { alertsToReminders, calendarDefaultAlerts, @@ -641,7 +641,7 @@ export function EventModal({ style={styles.dateBtn} onPress={() => setShowStartDate(true)} > - {formatCalendarDate(start, locale.code, 'weekdayMonthDay')} + {formatCalendarDay(start, locale, 'weekdayMonthDay')} {!allDay && ( setShowEndDate(true)} > - {formatCalendarDate(end, locale.code, 'weekdayMonthDay')} + {formatCalendarDay(end, locale, 'weekdayMonthDay')} {!allDay && ( makeStyles(c), [c]); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const { system } = useCalendarLocale(); // Pages are as wide as the panel (the window, or a pane on a tablet) and // re-measure on rotation. In a short window the panel takes at most about // half of it and its month scrolls inside. @@ -58,9 +59,10 @@ export function MonthDropdown({ const width = measuredWidth || windowWidth; const panelHeight = Math.min(PANEL_HEIGHT, Math.round(windowHeight * 0.55)); // The months are fixed while the panel is open; it remounts on each open. + // Jalali months for Persian, as the month grid shows them. const [months] = React.useState(() => { - const first = addMonths(startOfMonth(month), -MONTHS_EACH_SIDE); - return Array.from({ length: MONTHS_EACH_SIDE * 2 + 1 }, (_, i) => addMonths(first, i)); + const first = system.addMonths(system.startOfMonth(month), -MONTHS_EACH_SIDE); + return Array.from({ length: MONTHS_EACH_SIDE * 2 + 1 }, (_, i) => system.addMonths(first, i)); }); const lastIndexRef = React.useRef(MONTHS_EACH_SIDE); @@ -129,7 +131,7 @@ export function MonthDropdown({ } function monthKey(month: Date): string { - return `${month.getFullYear()}-${month.getMonth()}`; + return month.toISOString(); } function makeStyles(c: ThemePalette) { diff --git a/src/components/calendar/MonthScrollView.tsx b/src/components/calendar/MonthScrollView.tsx index 5e2a07df..af033c86 100644 --- a/src/components/calendar/MonthScrollView.tsx +++ b/src/components/calendar/MonthScrollView.tsx @@ -94,12 +94,12 @@ function MonthScrollViewInner({ onLongPressDate, }: MonthScrollViewProps) { const styles = useMonthStyles(); - const { locale } = useCalendarLocale(); + const { locale, system } = useCalendarLocale(); const index = React.useMemo( () => eventsByDay ?? buildEventDayIndex(events), [eventsByDay, events], ); - const opts = React.useMemo(() => ({ weekStartsOn }), [weekStartsOn]); + const opts = React.useMemo(() => ({ weekStartsOn, calendarSystem: system }), [weekStartsOn, system]); // One row per week of the window. A week keeps its array across window // changes, so growing the window doesn't re-render the rows already there. @@ -131,8 +131,16 @@ function MonthScrollViewInner({ // The month in focus: set by navigation, then by what scrolls under the // sample line. - const [activeMonth, setActiveMonth] = React.useState(() => monthKeyOf(focus.date)); + const [activeMonth, setActiveMonth] = React.useState(() => monthKeyOf(focus.date, system)); const activeMonthRef = React.useRef(activeMonth); + // Another calendar system (the language changed) counts months its own way. + const systemRef = React.useRef(system); + if (systemRef.current !== system) { + systemRef.current = system; + const key = monthKeyOf(focus.date, system); + activeMonthRef.current = key; + setActiveMonth(key); + } const onVisibleRef = React.useRef(onVisibleDateChange); onVisibleRef.current = onVisibleDateChange; const offsetRef = React.useRef(0); @@ -163,7 +171,7 @@ function MonthScrollViewInner({ if (!days) return; // Mid-week decides which month a row belongs to. const mid = days[3]; - const key = monthKeyOf(mid); + const key = monthKeyOf(mid, systemRef.current); if (key === activeMonthRef.current) return; activeMonthRef.current = key; setActiveMonth(key); @@ -191,14 +199,14 @@ function MonthScrollViewInner({ handledNonceRef.current = focus.nonce; const row = monthFocusRow(window, focus.date, weeks.length, opts); const currentRow = Math.round(offsetRef.current / rowHeight); - const key = monthKeyOf(focus.date); + const key = monthKeyOf(focus.date, system); activeMonthRef.current = key; setActiveMonth(key); listRef.current?.scrollToIndex({ index: row, animated: Math.abs(row - currentRow) <= SMOOTH_SCROLL_ROWS, }); - }, [focus, window, weeks.length, opts, rowHeight]); + }, [focus, window, weeks.length, opts, rowHeight, system]); const selectedKey = dayKey(selectedDate); // Today on a clock in the calendar's time zone. @@ -209,7 +217,7 @@ function MonthScrollViewInner({ return ( s.calendarTintedEvents); @@ -198,7 +196,7 @@ function MonthWeekRowInner({ const maxVisible = showTimeInMonthView ? MAX_CHIPS : MAX_DOTS; const visible = dayEvents.slice(0, maxVisible); const overflow = Math.max(0, dayEvents.length - maxVisible); - const monthLabel = labelMonths && d.getDate() === 1 ? format(d, 'MMM', { locale }) : null; + const monthLabel = labelMonths && isFirstOfMonth(d, system) ? monthStartLabel(d, system, locale, false) : null; return ( - {format(d, 'd')} + {dayNumberLabel(d, system)} {dayEvents.length > 0 && !showTimeInMonthView && ( @@ -330,7 +328,7 @@ function FullWeekRow({ colors: ThemePalette; }) { const { width } = useWindowDimensions(); - const { t } = useCalendarLocale(); + const { t, system } = useCalendarLocale(); // "Light event colors" (webmail calendarTintedEvents). const tinted = useSettingsStore((s) => s.calendarTintedEvents); const showTime = monthChipShowsTime(showTimeInMonthView, width / 7); @@ -345,7 +343,9 @@ function FullWeekRow({ const dayEvents = eventsOnDayFromIndex(index, d); const shown = fullCellChipCount(height, dayEvents.length); const overflow = dayEvents.length - shown; - const label = labelMonths && d.getDate() === 1 ? format(d, 'MMM d', { locale }) : format(d, 'd'); + const label = labelMonths && isFirstOfMonth(d, system) + ? monthStartLabel(d, system, locale, true) + : dayNumberLabel(d, system); return ( eventsByDay ?? buildEventDayIndex(events), [eventsByDay, events], ); - // The month grid as rows of 7 days. - const rows = React.useMemo(() => { - const calStart = startOfWeek(startOfMonth(currentDate), { weekStartsOn }); - const calEnd = endOfWeek(endOfMonth(currentDate), { weekStartsOn }); - const out: Date[][] = []; - let day = calStart; - while (day <= calEnd) { - out.push(Array.from({ length: 7 }, (_, i) => addDays(day, i))); - day = addDays(day, 7); - } - return out; - }, [currentDate, weekStartsOn]); - const activeMonth = monthKeyOf(currentDate); + // The month grid as rows of 7 days, in the calendar system's months. + const rows = React.useMemo( + () => monthGridRows(currentDate, weekStartsOn, system), + [currentDate, weekStartsOn, system], + ); + const activeMonth = monthKeyOf(currentDate, system); // Today on a clock in the calendar's time zone. const today = displayNow(); @@ -463,7 +456,7 @@ function MonthViewInner({ variant={fill ? 'full' : 'compact'} height={fullRowHeight} days={days} - activeMask={monthMask(days, activeMonth)} + activeMask={monthMask(days, activeMonth, system)} selectedIndex={dayIndexIn(days, selectedDate)} todayIndex={dayIndexIn(days, today)} index={index} diff --git a/src/components/calendar/RecurrenceEditor.tsx b/src/components/calendar/RecurrenceEditor.tsx index e7d28cb8..e2bd7f56 100644 --- a/src/components/calendar/RecurrenceEditor.tsx +++ b/src/components/calendar/RecurrenceEditor.tsx @@ -8,6 +8,7 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { Button } from '..'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { formatCalendarDay } from '../../lib/calendar-system'; import { EDITOR_FREQUENCIES, UNIT_LABEL_KEYS, @@ -113,7 +114,7 @@ function NumberInput({ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - const { locale, t } = useCalendarLocale(); + const { locale, t, system } = useCalendarLocale(); const [value, setValue] = React.useState(() => editorValueFromRule(rule, eventStart), @@ -245,7 +246,9 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre {value.endsMode === 'on' && ( setShowUntilPicker(true)}> - {format(parseISO(value.untilDate), 'MMM d, yyyy', { locale })} + {system.jalali + ? formatCalendarDay(parseISO(value.untilDate), locale, 'monthDayYear') + : format(parseISO(value.untilDate), 'MMM d, yyyy', { locale })} )} diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx index 9a44116f..046502ed 100644 --- a/src/components/calendar/TasksSheet.tsx +++ b/src/components/calendar/TasksSheet.tsx @@ -42,7 +42,7 @@ import { import { askBeforeDiscard } from '../../lib/calendar-editor-discard'; import { isWritableCalendar } from '../../lib/calendar-editability'; import { useCalendarLocale } from '../../lib/calendar-locale'; -import { formatCalendarDate } from '../../lib/date-format'; +import { formatCalendarDay } from '../../lib/calendar-system'; import { useSheetDrag } from '../../lib/use-sheet-drag'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { useSheetInsets } from '../../lib/use-layout'; @@ -233,8 +233,8 @@ export function TasksSheet({ if (!d || !task.due) return null; const hasTime = !task.showWithoutTime && !/^\d{4}-\d{2}-\d{2}$/.test(task.due); return hasTime - ? `${formatCalendarDate(d, locale.code, 'weekdayMonthDay')} · ${format(d, timePattern(timeFormat), { locale })}` - : formatCalendarDate(d, locale.code, 'weekdayMonthDay'); + ? `${formatCalendarDay(d, locale, 'weekdayMonthDay')} · ${format(d, timePattern(timeFormat), { locale })}` + : formatCalendarDay(d, locale, 'weekdayMonthDay'); }; const priorityColor = (level: PriorityLevel): string => @@ -321,7 +321,7 @@ export function TasksSheet({ > - {editor.due ? formatCalendarDate(editor.due, locale.code, 'monthDay') : t('calendar.tasks.due_date', 'Due date')} + {editor.due ? formatCalendarDay(editor.due, locale, 'monthDay') : t('calendar.tasks.due_date', 'Due date')} {editor.due && ( void; }) { + const { system } = useCalendarLocale(); return ( onPress?.(day)}> @@ -577,7 +579,7 @@ const DayHeaderCell = React.memo(function DayHeaderCell({ selected && !today && styles.dayHeaderNumberTextSelected, ]} > - {format(day, 'd')} + {dayNumberLabel(day, system)} diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index cfa39e0f..fb47be4b 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -13,6 +13,7 @@ import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useSettingsStore } from '../../stores/settings-store'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { dayNumberLabel } from '../../lib/calendar-system'; import { eventAccessibilityLabel } from '../../lib/calendar-a11y'; import { buildEventDayIndex, @@ -70,7 +71,7 @@ function WeekViewInner({ // "Light event colors" (webmail calendarTintedEvents). const tinted = useSettingsStore((s) => s.calendarTintedEvents); const styles = React.useMemo(() => makeStyles(c), [c]); - const { locale, t } = useCalendarLocale(); + const { locale, t, system } = useCalendarLocale(); const scrollRef = React.useRef(null); const index = React.useMemo( @@ -171,7 +172,7 @@ function WeekViewInner({ selected && !today && styles.dayHeaderNumberTextSelected, ]} > - {format(day, 'd')} + {dayNumberLabel(day, system)} diff --git a/src/lib/__tests__/calendar-system.test.ts b/src/lib/__tests__/calendar-system.test.ts new file mode 100644 index 00000000..96ec6301 --- /dev/null +++ b/src/lib/__tests__/calendar-system.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { enUS } from 'date-fns/locale/en-US'; +import { faIR } from 'date-fns/locale/fa-IR'; +import { fromJalali, isLeapJalaliYear, jalaliMonthLength, toJalali } from '../jalali'; +import { + calendarSystemFor, + dayNumberLabel, + dayRangeLabel, + formatCalendarDay, + gregorianCalendar, + isFirstOfMonth, + jalaliCalendar, + monthGridRows, + monthStartLabel, + monthTitle, +} from '../calendar-system'; +import { baseRange } from '../calendar-scroll-window'; +import { monthKeyOf, monthMask } from '../calendar-month-scroll'; + +// Ported from the webmail's lib/__tests__/jalali-utils.test.ts and +// hooks/__tests__/use-calendar-locale.test.ts. The conversion was also +// checked day by day from 1900 to 2200 against jalaali-js, which the webmail +// uses. + +const ymd = (d: Date) => [d.getFullYear(), d.getMonth() + 1, d.getDate()]; + +describe('Jalali dates', () => { + it('converts Gregorian days to Jalali and back', () => { + expect(toJalali(new Date(2026, 2, 21))).toEqual({ jy: 1405, jm: 1, jd: 1 }); + expect(toJalali(new Date(2026, 3, 29))).toEqual({ jy: 1405, jm: 2, jd: 9 }); + expect(toJalali(new Date(2025, 2, 20))).toEqual({ jy: 1403, jm: 12, jd: 30 }); + expect(ymd(fromJalali(1405, 7, 1))).toEqual([2026, 9, 23]); + expect(ymd(fromJalali(1403, 12, 30))).toEqual([2025, 3, 20]); + }); + + it('knows leap years and month lengths', () => { + expect(isLeapJalaliYear(1403)).toBe(true); + expect(isLeapJalaliYear(1404)).toBe(false); + expect(jalaliMonthLength(1404, 1)).toBe(31); + expect(jalaliMonthLength(1404, 7)).toBe(30); + expect(jalaliMonthLength(1404, 12)).toBe(29); + expect(jalaliMonthLength(1403, 12)).toBe(30); + }); +}); + +describe('the calendar system', () => { + it('is Jalali for the Persian UI only, as in the webmail', () => { + expect(calendarSystemFor('fa')).toBe(jalaliCalendar); + expect(calendarSystemFor('de')).toBe(gregorianCalendar); + expect(calendarSystemFor('ar')).toBe(gregorianCalendar); + }); + + it('builds the Jalali month grid from the week of 1 Mehr to the week of 30 Mehr, starting on Saturday', () => { + // 15 October 2026 is 23 Mehr 1405. + const rows = monthGridRows(new Date(2026, 9, 15), 6, jalaliCalendar); + const first = rows[0][0]; + const last = rows[rows.length - 1][6]; + expect(first.getDay()).toBe(6); + expect(rows.flat().some((d) => toJalali(d).jm === 7 && toJalali(d).jd === 1)).toBe(true); + expect(toJalali(rows[0][6]).jm === 7 || toJalali(rows[0][6]).jd === 1).toBe(true); + expect(rows.flat().some((d) => toJalali(d).jm === 7 && toJalali(d).jd === 30)).toBe(true); + // No whole week of another month at either end. + expect(rows[0].some((d) => toJalali(d).jm === 7)).toBe(true); + expect(rows[rows.length - 1].some((d) => toJalali(d).jm === 7)).toBe(true); + expect(last.getDay()).toBe(5); + }); + + it('starts a Gregorian month grid on Saturday when the week does', () => { + const rows = monthGridRows(new Date(2026, 9, 15), 6, gregorianCalendar); + expect(rows[0][0].getDay()).toBe(6); + expect(ymd(rows[0][0])).toEqual([2026, 9, 26]); + expect(ymd(rows[rows.length - 1][6])).toEqual([2026, 11, 6]); + }); + + it('gives the scrolling month the Jalali grid, and the Gregorian one without a calendar system', () => { + const date = new Date(2026, 9, 15); + const jalali = baseRange('month', date, { weekStartsOn: 6, calendarSystem: jalaliCalendar }); + const rows = monthGridRows(date, 6, jalaliCalendar); + expect(jalali.start.getTime()).toBe(rows[0][0].getTime()); + expect(jalali.end.getTime()).toBe(rows[rows.length - 1][6].getTime()); + const gregorian = baseRange('month', date, { weekStartsOn: 1 }); + expect(ymd(gregorian.start)).toEqual([2026, 9, 28]); + }); + + it('counts two days of Mehr in September and October as one month, and shades by it', () => { + const sept = new Date(2026, 8, 25); // 3 Mehr + const oct = new Date(2026, 9, 20); // 28 Mehr + expect(monthKeyOf(sept, jalaliCalendar)).toBe(monthKeyOf(oct, jalaliCalendar)); + expect(monthKeyOf(sept)).not.toBe(monthKeyOf(oct)); + const days = [new Date(2026, 9, 21), new Date(2026, 9, 22), new Date(2026, 9, 23)]; // 29, 30 Mehr, 1 Aban + expect(monthMask(days, monthKeyOf(oct, jalaliCalendar), jalaliCalendar)).toBe(0b011); + }); + + it('steps Jalali months, keeping the day where the month has it', () => { + // 31 Shahrivar 1405 -> 30 Mehr (Mehr has 30 days). + expect(toJalali(jalaliCalendar.addMonths(fromJalali(1405, 6, 31), 1))).toEqual({ jy: 1405, jm: 7, jd: 30 }); + expect(toJalali(jalaliCalendar.addMonths(fromJalali(1404, 12, 10), 1))).toEqual({ jy: 1405, jm: 1, jd: 10 }); + expect(toJalali(jalaliCalendar.addMonths(fromJalali(1405, 1, 10), -1))).toEqual({ jy: 1404, jm: 12, jd: 10 }); + const withTime = new Date(2026, 9, 15, 14, 30); + expect(jalaliCalendar.addMonths(withTime, 2).getHours()).toBe(14); + }); + + it('labels Jalali days and months as the webmail does', () => { + const day = new Date(2026, 3, 29); // 9 Ordibehesht 1405 + expect(dayNumberLabel(day, jalaliCalendar)).toBe('9'); + expect(dayNumberLabel(day, gregorianCalendar)).toBe('29'); + expect(monthTitle(day, jalaliCalendar, faIR, true)).toBe('اردیبهشت 1405'); + expect(monthTitle(day, jalaliCalendar, faIR, false)).toBe('اردیبهشت'); + expect(monthTitle(day, gregorianCalendar, enUS, true)).toBe('April 2026'); + expect(isFirstOfMonth(new Date(2026, 3, 21), jalaliCalendar)).toBe(true); + expect(monthStartLabel(new Date(2026, 3, 21), jalaliCalendar, faIR, true)).toBe('1 اردیبهشت'); + expect(monthStartLabel(new Date(2026, 4, 1), gregorianCalendar, enUS, true)).toBe('May 1'); + expect(dayRangeLabel(new Date(2026, 3, 25), new Date(2026, 4, 1), jalaliCalendar, faIR)).toBe('5 – 11 اردیبهشت'); + expect(dayRangeLabel(new Date(2026, 3, 18), new Date(2026, 3, 24), jalaliCalendar, faIR)).toBe('29 فروردین – 4 اردیبهشت'); + }); + + it('writes event dates with the Jalali day, month and year in Persian (useFormatEventDate)', () => { + const day = new Date(2026, 3, 29); + expect(formatCalendarDay(day, faIR, 'longWeekdayMonthDayYear')).toBe('چهارشنبه, 9 اردیبهشت 1405'); + expect(formatCalendarDay(day, faIR, 'monthDay')).toBe('9 اردیبهشت'); + expect(formatCalendarDay(day, enUS, 'monthDay')).toMatch(/Apr(il)? 29/); + }); +}); + +describe('the views follow the calendar system', () => { + const read = (p: string) => fs.readFileSync(path.resolve(__dirname, p), 'utf8'); + + it('builds the month grid and its shading from the calendar system', () => { + const month = read('../../components/calendar/MonthView.tsx'); + expect(month).toMatch(/monthGridRows\(currentDate, weekStartsOn, system\)/); + expect(month).toMatch(/monthMask\(days, activeMonth, system\)/); + expect(month).toMatch(/dayNumberLabel\(d, system\)/); + const scroll = read('../../components/calendar/MonthScrollView.tsx'); + expect(scroll).toMatch(/calendarSystem: system/); + expect(scroll).toMatch(/monthMask\(days, activeMonth, system\)/); + }); + + it('steps a month and titles the screen in the calendar system', () => { + const screen = read('../../screens/CalendarScreen.tsx'); + expect(screen).toMatch(/system\.addMonths\(base, -1\)/); + expect(screen).toMatch(/system\.addMonths\(base, 1\)/); + expect(screen).toMatch(/headerTitle\(titleDate, locale, system\)/); + expect(screen).toMatch(/calendarSystem: system/); + }); + + it('numbers the days of the week and day views in the calendar system', () => { + expect(read('../../components/calendar/WeekView.tsx')).toMatch(/dayNumberLabel\(day, system\)/); + expect(read('../../components/calendar/TimeGridScrollView.tsx')).toMatch(/dayNumberLabel\(day, system\)/); + expect(read('../../components/calendar/AgendaView.tsx')).toMatch(/dayNumberLabel\(date, system\)/); + }); +}); diff --git a/src/lib/calendar-a11y.ts b/src/lib/calendar-a11y.ts index 98cb478f..97fcbd6c 100644 --- a/src/lib/calendar-a11y.ts +++ b/src/lib/calendar-a11y.ts @@ -3,7 +3,7 @@ import type { Locale } from 'date-fns'; import type { Calendar, CalendarEvent } from '../api/types'; import { getEventDisplayEndDate, getEventStartDate, getPrimaryCalendarId, timePattern } from './calendar-utils'; import { isDeclinedByUser } from './calendar-participants'; -import { formatCalendarDate } from './date-format'; +import { formatCalendarDay } from './calendar-system'; type Translate = (key: string, fallback?: string, params?: Record) => string; @@ -27,7 +27,7 @@ export function participantRemoveAccessibilityLabel(attendee: { name?: string; e /** "Monday, November 2, 2026, 3 events" for a month day cell. */ export function dayCellAccessibilityLabel(day: Date, eventCount: number, t: Translate, locale: Locale): string { - const date = formatCalendarDate(day, locale.code, 'longWeekdayMonthDayYear'); + const date = formatCalendarDay(day, locale, 'longWeekdayMonthDayYear'); if (eventCount === 0) return date; return `${date}, ${t('calendar.a11y.event_count', '{count, plural, one {# event} other {# events}}', { count: eventCount })}`; } @@ -49,10 +49,10 @@ export function eventAccessibilityLabel( const end = getEventDisplayEndDate(event); const valid = (d: Date) => !isNaN(d.getTime()); if (valid(start)) { - const date = formatCalendarDate(start, locale.code, 'longWeekdayMonthDayYear'); + const date = formatCalendarDay(start, locale, 'longWeekdayMonthDayYear'); if (event.showWithoutTime) { const last = valid(end) && !isSameDay(start, end) - ? ` – ${formatCalendarDate(end, locale.code, 'longWeekdayMonthDayYear')}` + ? ` – ${formatCalendarDay(end, locale, 'longWeekdayMonthDayYear')}` : ''; parts.push(date + last, t('calendar.events.all_day', 'All day')); } else { @@ -61,7 +61,7 @@ export function eventAccessibilityLabel( if (valid(end) && end.getTime() > start.getTime()) { range += isSameDay(start, end) ? ` – ${format(end, time, { locale })}` - : ` – ${formatCalendarDate(end, locale.code, 'longWeekdayMonthDayYear')} ${format(end, time, { locale })}`; + : ` – ${formatCalendarDay(end, locale, 'longWeekdayMonthDayYear')} ${format(end, time, { locale })}`; } parts.push(date, range); } diff --git a/src/lib/calendar-locale.ts b/src/lib/calendar-locale.ts index 5f857c66..f929c24c 100644 --- a/src/lib/calendar-locale.ts +++ b/src/lib/calendar-locale.ts @@ -2,6 +2,7 @@ import type { Locale } from 'date-fns'; import { enUS } from 'date-fns/locale/en-US'; import { useLocaleStore, type TranslateFn } from '../stores/locale-store'; import type { LocaleCode } from '../i18n'; +import { calendarSystemFor, type CalendarSystem } from './calendar-system'; // date-fns locale for each UI language so month/day names, "EEE, MMM d" // headers and the like render in the user's language (the webmail localizes @@ -55,9 +56,12 @@ export function getDateFnsLocale(code: LocaleCode | string | null | undefined): return locale; } -/** `{ locale }` options for date-fns `format()` plus the translate function. */ -export function useCalendarLocale(): { locale: Locale; t: TranslateFn } { +/** + * `{ locale }` options for date-fns `format()`, the translate function and + * the calendar system (Jalali for Persian, as in the webmail). + */ +export function useCalendarLocale(): { locale: Locale; t: TranslateFn; system: CalendarSystem } { const code = useLocaleStore((s) => s.locale); const t = useLocaleStore((s) => s.t); - return { locale: getDateFnsLocale(code), t }; + return { locale: getDateFnsLocale(code), t, system: calendarSystemFor(code) }; } diff --git a/src/lib/calendar-month-scroll.ts b/src/lib/calendar-month-scroll.ts index 763c4d17..65423b5a 100644 --- a/src/lib/calendar-month-scroll.ts +++ b/src/lib/calendar-month-scroll.ts @@ -1,5 +1,6 @@ import { addDays, differenceInCalendarDays } from 'date-fns'; import { baseRange, type DayRange, type ScrollWindowOptions } from './calendar-scroll-window'; +import { gregorianCalendar, type CalendarSystem } from './calendar-system'; /** * Geometry of the freely scrolling month view (#759, webmail @@ -63,9 +64,9 @@ export function sampledRow( return Math.max(0, Math.min(rowCount - 1, row)); } -/** Year and month as one comparable number. */ -export function monthKeyOf(date: Date): number { - return date.getFullYear() * 12 + date.getMonth(); +/** Year and month as one comparable number, in the calendar system's months. */ +export function monthKeyOf(date: Date, system: CalendarSystem = gregorianCalendar): number { + return system.monthKey(date); } /** @@ -73,10 +74,10 @@ export function monthKeyOf(date: Date): number { * compare this instead of the month itself, so moving to another month only * re-renders the rows whose shading changes. */ -export function monthMask(days: Date[], monthKey: number): number { +export function monthMask(days: Date[], monthKey: number, system: CalendarSystem = gregorianCalendar): number { let mask = 0; days.forEach((day, i) => { - if (monthKeyOf(day) === monthKey) mask |= 1 << i; + if (monthKeyOf(day, system) === monthKey) mask |= 1 << i; }); return mask; } diff --git a/src/lib/calendar-scroll-window.ts b/src/lib/calendar-scroll-window.ts index a8af49de..3e711b2c 100644 --- a/src/lib/calendar-scroll-window.ts +++ b/src/lib/calendar-scroll-window.ts @@ -8,6 +8,7 @@ import { subDays, } from 'date-fns'; import { dayKey } from './calendar-utils'; +import { monthGridRange, type CalendarSystem } from './calendar-system'; /** * The calendar views scroll freely (#759; webmail lib/calendar-scroll-window): @@ -50,6 +51,8 @@ export interface CalendarFocus { export interface ScrollWindowOptions { weekStartsOn: WeekStartsOn; + /** What a month is (Jalali for Persian); Gregorian when omitted. */ + calendarSystem?: CalendarSystem; } /** First growth step in days; every further step doubles the side. */ @@ -114,6 +117,7 @@ export function baseRange(mode: ScrollViewMode, date: Date, opts: ScrollWindowOp end: startOfDay(endOfWeek(day, { weekStartsOn: opts.weekStartsOn })), }; case 'month': + if (opts.calendarSystem) return monthGridRange(day, opts.weekStartsOn, opts.calendarSystem); return { start: startOfWeek(startOfMonth(day), { weekStartsOn: opts.weekStartsOn }), end: startOfDay(endOfWeek(endOfMonth(day), { weekStartsOn: opts.weekStartsOn })), diff --git a/src/lib/calendar-system.ts b/src/lib/calendar-system.ts new file mode 100644 index 00000000..aade4d92 --- /dev/null +++ b/src/lib/calendar-system.ts @@ -0,0 +1,198 @@ +import { + addDays, + addMonths as addGregorianMonths, + endOfWeek, + format, + startOfDay, + startOfWeek, + type Locale, +} from 'date-fns'; +import { JALALI_MONTHS, fromJalali, jalaliMonthLength, toJalali } from './jalali'; +import { formatCalendarDate, type CalendarDateStyle } from './date-format'; + +// The calendar system the calendar's months, day numbers and headers follow: +// Gregorian, or Jalali for the Persian UI, as the webmail decides it +// (lib/jalali-utils.ts shouldUseJalaliCalendar, hooks/use-calendar-locale.ts). +// Dates stay Gregorian Dates everywhere; only what a month is, and how a day +// or a month is labelled, changes. + +export type WeekStart = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +export interface CalendarSystem { + jalali: boolean; + /** Local midnight of the first day of `date`'s month. */ + startOfMonth(date: Date): Date; + /** Local midnight of the last day of `date`'s month. */ + lastDayOfMonth(date: Date): Date; + /** The same day `n` months away, clamped to the end of a shorter month. */ + addMonths(date: Date, n: number): Date; + /** Year and month as one comparable number. */ + monthKey(date: Date): number; + /** Day of the month, 1-31. */ + dayOfMonth(date: Date): number; + /** Month name, in the UI language (Persian names for Jalali). */ + monthName(date: Date, width: 'long' | 'short', locale: Locale): string; + /** Year of `date`'s month. */ + year(date: Date): number; +} + +export const gregorianCalendar: CalendarSystem = { + jalali: false, + startOfMonth: (date) => new Date(date.getFullYear(), date.getMonth(), 1), + lastDayOfMonth: (date) => new Date(date.getFullYear(), date.getMonth() + 1, 0), + addMonths: (date, n) => addGregorianMonths(date, n), + monthKey: (date) => date.getFullYear() * 12 + date.getMonth(), + dayOfMonth: (date) => date.getDate(), + // The long name stands alone ("LLLL": some languages decline it inside a + // date); the short one labels the 1st in a month grid. + monthName: (date, width, locale) => format(date, width === 'long' ? 'LLLL' : 'MMM', { locale }), + year: (date) => date.getFullYear(), +}; + +export const jalaliCalendar: CalendarSystem = { + jalali: true, + startOfMonth: (date) => { + const { jy, jm } = toJalali(date); + return fromJalali(jy, jm, 1); + }, + lastDayOfMonth: (date) => { + const { jy, jm } = toJalali(date); + return fromJalali(jy, jm, jalaliMonthLength(jy, jm)); + }, + addMonths: (date, n) => { + const { jy, jm, jd } = toJalali(date); + const index = jy * 12 + (jm - 1) + n; + const y = Math.floor(index / 12); + const m = index - y * 12 + 1; + const day = fromJalali(y, m, Math.min(jd, jalaliMonthLength(y, m))); + // Keep the time of day, as date-fns addMonths does. + day.setHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); + return day; + }, + monthKey: (date) => { + const { jy, jm } = toJalali(date); + return jy * 12 + (jm - 1); + }, + dayOfMonth: (date) => toJalali(date).jd, + // The webmail shows the Persian names, and the first three letters on a + // phone (formatMonthYearShort). + monthName: (date, width) => { + const name = JALALI_MONTHS[toJalali(date).jm - 1]; + return width === 'long' ? name : name.slice(0, 3); + }, + year: (date) => toJalali(date).jy, +}; + +/** Whether the calendar shows Jalali dates: keyed off the Persian UI, as in the webmail. */ +export function shouldUseJalaliCalendar(locale: string | null | undefined): boolean { + return locale === 'fa'; +} + +export function calendarSystemFor(locale: string | null | undefined): CalendarSystem { + return shouldUseJalaliCalendar(locale) ? jalaliCalendar : gregorianCalendar; +} + +/** The days of `date`'s month grid: whole weeks from the one with the 1st to the one with the last day. */ +export function monthGridRange( + date: Date, + weekStartsOn: WeekStart, + system: CalendarSystem, +): { start: Date; end: Date } { + return { + start: startOfWeek(system.startOfMonth(date), { weekStartsOn }), + end: startOfDay(endOfWeek(system.lastDayOfMonth(date), { weekStartsOn })), + }; +} + +/** `date`'s month grid as rows of seven days. */ +export function monthGridRows(date: Date, weekStartsOn: WeekStart, system: CalendarSystem): Date[][] { + const { start, end } = monthGridRange(date, weekStartsOn, system); + const rows: Date[][] = []; + for (let day = start; day <= end; day = addDays(day, 7)) { + rows.push(Array.from({ length: 7 }, (_, i) => addDays(day, i))); + } + return rows; +} + +/** Whether `date` is the first day of its month. */ +export function isFirstOfMonth(date: Date, system: CalendarSystem): boolean { + return system.dayOfMonth(date) === 1; +} + +/** "Mehr 1405" / "October 2026"; without the year when `withYear` is false. */ +export function monthTitle(date: Date, system: CalendarSystem, locale: Locale, withYear: boolean): string { + const name = system.monthName(date, 'long', locale); + if (!withYear) return name; + if (!system.jalali) return format(date, 'LLLL yyyy', { locale }); + return `${name} ${system.year(date)}`; +} + +/** Day number and month of `date`, e.g. "9 Ordibehesht" or "Apr 29". */ +export function shortDayLabel(date: Date, system: CalendarSystem, locale: Locale): string { + if (!system.jalali) return format(date, 'MMM d', { locale }); + return `${system.dayOfMonth(date)} ${system.monthName(date, 'long', locale)}`; +} + +/** + * A date with weekday, day, month and year, e.g. "Wed, Apr 29, 2026", or + * "چهارشنبه, 9 اردیبهشت 1405" for Jalali (webmail useFormatEventDate). + */ +export function fullDateLabel(date: Date, system: CalendarSystem, locale: Locale, gregorianPattern = 'EEE, PP'): string { + if (!system.jalali) return format(date, gregorianPattern, { locale }); + const { jy, jm, jd } = toJalali(date); + return `${format(date, 'EEEE', { locale })}, ${jd} ${JALALI_MONTHS[jm - 1]} ${jy}`; +} + +/** "6 – 12 Mehr" / "Oct 6 – 12": the days a range of days covers. */ +export function dayRangeLabel(start: Date, end: Date, system: CalendarSystem, locale: Locale): string { + if (!system.jalali) { + const sameMonth = start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear(); + return `${format(start, 'MMM d', { locale })} – ${format(end, sameMonth ? 'd' : 'MMM d', { locale })}`; + } + const a = toJalali(start); + const b = toJalali(end); + if (a.jy === b.jy && a.jm === b.jm) return `${a.jd} – ${b.jd} ${JALALI_MONTHS[a.jm - 1]}`; + return `${a.jd} ${JALALI_MONTHS[a.jm - 1]} – ${b.jd} ${JALALI_MONTHS[b.jm - 1]}`; +} + +/** A day's number in the grid: "29", or "9" for 9 Ordibehesht. */ +export function dayNumberLabel(date: Date, system: CalendarSystem): string { + return String(system.dayOfMonth(date)); +} + +/** + * The label of a month's first day in a grid that runs through months: + * "Oct" or "Oct 1"; the Persian month name, with "1" before it, for Jalali. + */ +export function monthStartLabel(date: Date, system: CalendarSystem, locale: Locale, withDay: boolean): string { + if (!system.jalali) return format(date, withDay ? 'MMM d' : 'MMM', { locale }); + const name = system.monthName(date, 'long', locale); + return withDay ? `${system.dayOfMonth(date)} ${name}` : name; +} + +/** The calendar system of a date-fns locale: Jalali for Persian. */ +export function calendarSystemOfLocale(locale: Locale): CalendarSystem { + return calendarSystemFor(locale.code?.split('-')[0]); +} + +/** + * A calendar date in one of formatCalendarDate's styles. For Jalali, the + * Persian weekday and month with the Jalali day and year, as the webmail's + * useFormatEventDate writes them ("چهارشنبه, 9 اردیبهشت 1405"). + */ +export function formatCalendarDay(date: Date, locale: Locale, style: CalendarDateStyle): string { + const system = calendarSystemOfLocale(locale); + if (!system.jalali) return formatCalendarDate(date, locale.code, style); + const { jy, jm, jd } = toJalali(date); + const month = JALALI_MONTHS[jm - 1]; + switch (style) { + case 'longWeekdayMonthDayYear': + return `${format(date, 'EEEE', { locale })}, ${jd} ${month} ${jy}`; + case 'weekdayMonthDay': + return `${format(date, 'EEEE', { locale })}, ${jd} ${month}`; + case 'monthDay': + return `${jd} ${month}`; + case 'monthDayYear': + return `${jd} ${month} ${jy}`; + } +} diff --git a/src/lib/jalali.ts b/src/lib/jalali.ts new file mode 100644 index 00000000..cdd5d5d8 --- /dev/null +++ b/src/lib/jalali.ts @@ -0,0 +1,137 @@ +// Jalali (Persian, Solar Hijri) calendar arithmetic. The calendar keeps every +// date as a Gregorian Date; these functions convert at the display layer only, +// as the webmail's lib/jalali-utils.ts does. +// +// The conversion is a port of jalaali-js (https://github.com/jalaali/jalaali-js, +// MIT, Behrang Noruzi Niya), which the webmail uses as a dependency. The +// algorithm is Kazimierz M. Borkowski's, valid for Jalali years -61 to 3177. + +/** A Jalali date: year (e.g. 1405), month 1 (Farvardin) to 12 (Esfand), day 1-31. */ +export interface JalaliDate { + jy: number; + jm: number; + jd: number; +} + +const BREAKS = [ + -61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178, +]; + +function div(a: number, b: number): number { + return Math.trunc(a / b); +} + +function mod(a: number, b: number): number { + return a - Math.trunc(a / b) * b; +} + +/** Julian day number of a Gregorian date. */ +function g2d(gy: number, gm: number, gd: number): number { + let d = div((gy + div(gm - 8, 6) + 100100) * 1461, 4) + + div(153 * mod(gm + 9, 12) + 2, 5) + + gd - 34840408; + d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752; + return d; +} + +/** Gregorian date of a Julian day number. */ +function d2g(jdn: number): { gy: number; gm: number; gd: number } { + let j = 4 * jdn + 139361631; + j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908; + const i = div(mod(j, 1461), 4) * 5 + 308; + const gd = div(mod(i, 153), 5) + 1; + const gm = mod(div(i, 153), 12) + 1; + const gy = div(j, 1461) - 100100 + div(8 - gm, 6); + return { gy, gm, gd }; +} + +/** + * For Jalali year `jy`: the Gregorian year it starts in, the day of March + * that is 1 Farvardin, and how many years have passed since the last leap + * year (0 = this one is a leap year). + */ +function jalCal(jy: number): { leap: number; gy: number; march: number } { + const bl = BREAKS.length; + const gy = jy + 621; + let leapJ = -14; + let jp = BREAKS[0]; + let jump = 0; + if (jy < jp || jy >= BREAKS[bl - 1]) throw new RangeError(`Invalid Jalali year ${jy}`); + for (let i = 1; i < bl; i += 1) { + const jm = BREAKS[i]; + jump = jm - jp; + if (jy < jm) break; + leapJ = leapJ + div(jump, 33) * 8 + div(mod(jump, 33), 4); + jp = jm; + } + let n = jy - jp; + leapJ = leapJ + div(n, 33) * 8 + div(mod(n, 33) + 3, 4); + if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1; + const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150; + const march = 20 + leapJ - leapG; + if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33; + let leap = mod(mod(n + 1, 33) - 1, 4); + if (leap === -1) leap = 4; + return { leap, gy, march }; +} + +function j2d(jy: number, jm: number, jd: number): number { + const r = jalCal(jy); + return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1; +} + +function d2j(jdn: number): JalaliDate { + const { gy } = d2g(jdn); + let jy = gy - 621; + const r = jalCal(jy); + const jdn1f = g2d(gy, 3, r.march); + let k = jdn - jdn1f; + if (k >= 0) { + if (k <= 185) return { jy, jm: 1 + div(k, 31), jd: mod(k, 31) + 1 }; + k -= 186; + } else { + jy -= 1; + k += 179; + if (r.leap === 1) k += 1; + } + return { jy, jm: 7 + div(k, 30), jd: mod(k, 30) + 1 }; +} + +/** Whether Jalali year `jy` has 366 days (Esfand 30). */ +export function isLeapJalaliYear(jy: number): boolean { + return jalCal(jy).leap === 0; +} + +/** Days in a Jalali month. */ +export function jalaliMonthLength(jy: number, jm: number): number { + if (jm <= 6) return 31; + if (jm <= 11) return 30; + return isLeapJalaliYear(jy) ? 30 : 29; +} + +/** The Jalali date of a Date's local calendar day. */ +export function toJalali(date: Date): JalaliDate { + return d2j(g2d(date.getFullYear(), date.getMonth() + 1, date.getDate())); +} + +/** Local midnight of a Jalali date. */ +export function fromJalali(jy: number, jm: number, jd: number): Date { + const { gy, gm, gd } = d2g(j2d(jy, jm, jd)); + return new Date(gy, gm - 1, gd); +} + +/** Persian month names, Farvardin to Esfand (webmail JALALI_MONTHS). */ +export const JALALI_MONTHS: readonly string[] = [ + 'فروردین', + 'اردیبهشت', + 'خرداد', + 'تیر', + 'مرداد', + 'شهریور', + 'مهر', + 'آبان', + 'آذر', + 'دی', + 'بهمن', + 'اسفند', +]; diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 7b6a5177..7ba8289e 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -29,16 +29,14 @@ import { Square, } from '../components/icons'; import { - format, addDays, subDays, - addMonths, - subMonths, addWeeks, subWeeks, type Locale, } from 'date-fns'; import { useCalendarLocale } from '../lib/calendar-locale'; +import { dayNumberLabel, monthTitle, type CalendarSystem } from '../lib/calendar-system'; import { displayNow } from '../lib/calendar-timezone'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; @@ -153,10 +151,11 @@ type PendingAction = // the next arrow press or edge usually finds them there already. const RANGE_MARGIN_DAYS = 14; -// The month on screen; the year only when it isn't this year. -function headerTitle(date: Date, locale: Locale): string { - const sameYear = date.getFullYear() === displayNow().getFullYear(); - return format(date, sameYear ? 'LLLL' : 'LLLL yyyy', { locale }); +// The month on screen; the year only when it isn't this year. Jalali +// months and years in Persian, as the webmail's toolbar shows them. +export function headerTitle(date: Date, locale: Locale, system: CalendarSystem): string { + const sameYear = system.year(date) === system.year(displayNow()); + return monthTitle(date, system, locale, !sameYear); } export default function CalendarScreen() { @@ -165,7 +164,7 @@ export default function CalendarScreen() { const t = useLocaleStore((s) => s.t); const currentUserEmails = useUserCalendarAddresses(); const resolveContactName = useContactNameResolver(); - const { locale } = useCalendarLocale(); + const { locale, system } = useCalendarLocale(); const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView); const calendarShowTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth); const showTasksOnCalendar = useSettingsStore((s) => s.showTasksOnCalendar); @@ -197,8 +196,8 @@ export default function CalendarScreen() { // shows exactly one period. Navigation moves the focus and, when that // leaves the window, starts a fresh window there. const windowOptions = React.useMemo( - () => ({ weekStartsOn: calendarFirstDayOfWeek }), - [calendarFirstDayOfWeek], + () => ({ weekStartsOn: calendarFirstDayOfWeek, calendarSystem: system }), + [calendarFirstDayOfWeek, system], ); const [focus, setFocus] = React.useState(() => ({ date: displayNow(), nonce: 0 })); // The day the scrolled view shows at its top, while it differs from the focus. @@ -501,9 +500,9 @@ export default function CalendarScreen() { viewMode === 'week' ? subWeeks(base, 1) : viewMode === '3day' ? subDays(base, multiDays) : viewMode === 'day' ? subDays(base, 1) - : subMonths(base, 1), + : system.addMonths(base, -1), ); - }, [viewMode, visibleDate, focus.date, jumpTo, multiDays]); + }, [viewMode, visibleDate, focus.date, jumpTo, multiDays, system]); const goNext = React.useCallback(() => { const base = visibleDate ?? focus.date; @@ -511,9 +510,9 @@ export default function CalendarScreen() { viewMode === 'week' ? addWeeks(base, 1) : viewMode === '3day' ? addDays(base, multiDays) : viewMode === 'day' ? addDays(base, 1) - : addMonths(base, 1), + : system.addMonths(base, 1), ); - }, [viewMode, visibleDate, focus.date, jumpTo, multiDays]); + }, [viewMode, visibleDate, focus.date, jumpTo, multiDays, system]); const goToday = React.useCallback(() => { jumpTo(displayNow()); @@ -1038,11 +1037,11 @@ export default function CalendarScreen() { style={styles.titleBtn} hitSlop={6} accessibilityRole="button" - accessibilityLabel={monthButtonAccessibilityLabel(headerTitle(titleDate, locale), t)} + accessibilityLabel={monthButtonAccessibilityLabel(headerTitle(titleDate, locale, system), t)} accessibilityState={{ expanded: monthPanelOpen }} > - {headerTitle(titleDate, locale)} + {headerTitle(titleDate, locale, system)} @@ -1095,7 +1094,7 @@ export default function CalendarScreen() { accessibilityLabel={t('calendar.events.go_to_today', 'Go to today')} > - {format(displayNow(), 'd')} + {dayNumberLabel(displayNow(), system)} {enableCalendarTasks && (