feat: show calendar months, days and dates in the Jalali calendar for Persian

This commit is contained in:
Linus Rath
2026-10-31 14:45:24 +01:00
parent d663a5e282
commit f1297b6be8
19 changed files with 606 additions and 95 deletions
+14 -12
View File
@@ -23,6 +23,7 @@ import {
import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window'; import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window';
import { buildAgendaDays, findAgendaFocusIndex, type AgendaDay } from '../../lib/calendar-agenda'; import { buildAgendaDays, findAgendaFocusIndex, type AgendaDay } from '../../lib/calendar-agenda';
import { useCalendarLocale } from '../../lib/calendar-locale'; import { useCalendarLocale } from '../../lib/calendar-locale';
import { dayNumberLabel, dayRangeLabel, fullDateLabel, monthTitle, type CalendarSystem } from '../../lib/calendar-system';
import { useLocaleStore } from '../../stores/locale-store'; import { useLocaleStore } from '../../stores/locale-store';
import { EventCard } from './EventCard'; import { EventCard } from './EventCard';
@@ -63,12 +64,12 @@ interface DaySection extends AgendaDay {
weekTitle: string | null; weekTitle: string | null;
} }
// The week separators: "Oct 11 – 17", or "Sep 27 – Oct 3" across months. // The week separators: "Oct 11 – 17", or "Sep 27 – Oct 3" across months
function formatWeekRange(date: Date, weekStartsOn: 0 | 1 | 6, locale: Locale): string { // (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 start = startOfWeek(date, { weekStartsOn });
const end = endOfWeek(date, { weekStartsOn }); const end = endOfWeek(date, { weekStartsOn });
const sameMonth = start.getMonth() === end.getMonth(); return dayRangeLabel(start, end, system, locale);
return `${format(start, 'MMM d', { locale })} – ${format(end, sameMonth ? 'd' : 'MMM d', { locale })}`;
} }
const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 1 }; const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 1 };
@@ -102,7 +103,7 @@ export function AgendaView({
}: AgendaViewProps) { }: AgendaViewProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale(); const { locale, t, system } = useCalendarLocale();
const tp = useLocaleStore((s) => s.t); const tp = useLocaleStore((s) => s.t);
const index = React.useMemo( const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events), () => eventsByDay ?? buildEventDayIndex(events),
@@ -115,15 +116,15 @@ export function AgendaView({
let lastMonth = ''; let lastMonth = '';
let lastWeek = ''; let lastWeek = '';
return buildAgendaDays(index, loaded, displayNow()).map((day) => { 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 week = dayKey(startOfWeek(day.date, { weekStartsOn }));
const monthTitle = month !== lastMonth ? format(day.date, 'LLLL yyyy', { locale }) : null; const title = month !== lastMonth ? monthTitle(day.date, system, locale, true) : null;
const weekTitle = week !== lastWeek ? formatWeekRange(day.date, weekStartsOn, locale) : null; const weekTitle = week !== lastWeek ? formatWeekRange(day.date, weekStartsOn, locale, system) : null;
lastMonth = month; lastMonth = month;
lastWeek = week; 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<SectionList<CalendarEvent, DaySection>>(null); const listRef = React.useRef<SectionList<CalendarEvent, DaySection>>(null);
const sectionsRef = React.useRef(sections); const sectionsRef = React.useRef(sections);
@@ -245,7 +246,7 @@ export function AgendaView({
sizeRef.current.content = h; 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 = ( const header = (
<View style={styles.edge}> <View style={styles.edge}>
@@ -383,6 +384,7 @@ function DateColumn({
locale: Locale; locale: Locale;
styles: AgendaStyles; styles: AgendaStyles;
}) { }) {
const { system } = useCalendarLocale();
if (!show) return <View style={styles.dateCol} />; if (!show) return <View style={styles.dateCol} />;
const today = isDisplayToday(date); const today = isDisplayToday(date);
return ( return (
@@ -392,7 +394,7 @@ function DateColumn({
</Text> </Text>
<View style={[styles.dateNumber, today && styles.dateNumberToday]}> <View style={[styles.dateNumber, today && styles.dateNumberToday]}>
<Text style={[styles.dateNumberText, today && styles.dateNumberTextToday]}> <Text style={[styles.dateNumberText, today && styles.dateNumberTextToday]}>
{format(date, 'd')} {dayNumberLabel(date, system)}
</Text> </Text>
</View> </View>
</View> </View>
@@ -17,6 +17,7 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { useCalendarStore, searchCalendarEvents } from '../../stores/calendar-store'; import { useCalendarStore, searchCalendarEvents } from '../../stores/calendar-store';
import { useCalendarLocale } from '../../lib/calendar-locale'; import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDay } from '../../lib/calendar-system';
import { displayNow } from '../../lib/calendar-timezone'; import { displayNow } from '../../lib/calendar-timezone';
import { eventMatches, orderSearchResults, searchTerms } from '../../lib/calendar-search'; import { eventMatches, orderSearchResults, searchTerms } from '../../lib/calendar-search';
import { import {
@@ -62,7 +63,7 @@ export function CalendarSearchSheet({
}: CalendarSearchSheetProps) { }: CalendarSearchSheetProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale(); const { locale, t, system } = useCalendarLocale();
const loaded = useCalendarStore((s) => s.events); const loaded = useCalendarStore((s) => s.events);
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const [remote, setRemote] = React.useState<{ query: string; events: CalendarEvent[] } | null>(null); const [remote, setRemote] = React.useState<{ query: string; events: CalendarEvent[] } | null>(null);
@@ -149,7 +150,9 @@ export function CalendarSearchSheet({
> >
<View style={styles.when}> <View style={styles.when}>
<Text style={styles.whenDay}> <Text style={styles.whenDay}>
{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 })}
</Text> </Text>
<Text style={styles.whenTime}> <Text style={styles.whenTime}>
{event.showWithoutTime {event.showWithoutTime
+5 -5
View File
@@ -57,7 +57,7 @@ import {
import { getEventEditability } from '../../lib/calendar-editability'; import { getEventEditability } from '../../lib/calendar-editability';
import { useContactNameResolver } from '../../lib/contact-name-resolver'; import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { useCalendarLocale } from '../../lib/calendar-locale'; 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 { useSheetDrag } from '../../lib/use-sheet-drag';
import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrence'; import { recurrenceEndLabel, recurrenceIntervalLabel } from '../../lib/recurrence';
import type { TranslateFn } from '../../stores/locale-store'; import type { TranslateFn } from '../../stores/locale-store';
@@ -99,9 +99,9 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l
start.getMonth() === end.getMonth() && start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate() 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 tp = timePattern(timeFormat);
const sameDay = const sameDay =
@@ -109,9 +109,9 @@ function formatRange(event: CalendarEvent, timeFormat: TimeFormat | undefined, l
start.getMonth() === end.getMonth() && start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate(); start.getDate() === end.getDate();
if (sameDay) { 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 { function recurrenceLabel(event: CalendarEvent, t: TranslateFn, locale: Locale): string | null {
+3 -3
View File
@@ -48,7 +48,7 @@ import { useSettingsStore } from '../../stores/settings-store';
import { CalendarAccountChangedError } from '../../stores/calendar-store'; import { CalendarAccountChangedError } from '../../stores/calendar-store';
import { canCreateEventsIn } from '../../lib/calendar-editability'; import { canCreateEventsIn } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale'; import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDate } from '../../lib/date-format'; import { formatCalendarDay } from '../../lib/calendar-system';
import { import {
alertsToReminders, alertsToReminders,
calendarDefaultAlerts, calendarDefaultAlerts,
@@ -641,7 +641,7 @@ export function EventModal({
style={styles.dateBtn} style={styles.dateBtn}
onPress={() => setShowStartDate(true)} onPress={() => setShowStartDate(true)}
> >
<Text style={styles.dateText}>{formatCalendarDate(start, locale.code, 'weekdayMonthDay')}</Text> <Text style={styles.dateText}>{formatCalendarDay(start, locale, 'weekdayMonthDay')}</Text>
</Pressable> </Pressable>
{!allDay && ( {!allDay && (
<Pressable <Pressable
@@ -661,7 +661,7 @@ export function EventModal({
style={styles.dateBtn} style={styles.dateBtn}
onPress={() => setShowEndDate(true)} onPress={() => setShowEndDate(true)}
> >
<Text style={styles.dateText}>{formatCalendarDate(end, locale.code, 'weekdayMonthDay')}</Text> <Text style={styles.dateText}>{formatCalendarDay(end, locale, 'weekdayMonthDay')}</Text>
</Pressable> </Pressable>
{!allDay && ( {!allDay && (
<Pressable <Pressable
+6 -4
View File
@@ -9,11 +9,11 @@ import {
type NativeScrollEvent, type NativeScrollEvent,
type NativeSyntheticEvent, type NativeSyntheticEvent,
} from 'react-native'; } from 'react-native';
import { addMonths, startOfMonth } from 'date-fns';
import type { Calendar } from '../../api/types'; import type { Calendar } from '../../api/types';
import type { ThemePalette } from '../../theme/tokens'; import type { ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import type { EventDayIndex } from '../../lib/calendar-utils'; import type { EventDayIndex } from '../../lib/calendar-utils';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { MONTH_ROW_HEIGHT, MonthView } from './MonthView'; import { MONTH_ROW_HEIGHT, MonthView } from './MonthView';
type WeekStart = 0 | 1 | 6; type WeekStart = 0 | 1 | 6;
@@ -51,6 +51,7 @@ export function MonthDropdown({
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const { width: windowWidth, height: windowHeight } = useWindowDimensions(); 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 // 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 // re-measure on rotation. In a short window the panel takes at most about
// half of it and its month scrolls inside. // half of it and its month scrolls inside.
@@ -58,9 +59,10 @@ export function MonthDropdown({
const width = measuredWidth || windowWidth; const width = measuredWidth || windowWidth;
const panelHeight = Math.min(PANEL_HEIGHT, Math.round(windowHeight * 0.55)); 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. // 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 [months] = React.useState(() => {
const first = addMonths(startOfMonth(month), -MONTHS_EACH_SIDE); const first = system.addMonths(system.startOfMonth(month), -MONTHS_EACH_SIDE);
return Array.from({ length: MONTHS_EACH_SIDE * 2 + 1 }, (_, i) => addMonths(first, i)); return Array.from({ length: MONTHS_EACH_SIDE * 2 + 1 }, (_, i) => system.addMonths(first, i));
}); });
const lastIndexRef = React.useRef(MONTHS_EACH_SIDE); const lastIndexRef = React.useRef(MONTHS_EACH_SIDE);
@@ -129,7 +131,7 @@ export function MonthDropdown({
} }
function monthKey(month: Date): string { function monthKey(month: Date): string {
return `${month.getFullYear()}-${month.getMonth()}`; return month.toISOString();
} }
function makeStyles(c: ThemePalette) { function makeStyles(c: ThemePalette) {
+16 -7
View File
@@ -94,12 +94,12 @@ function MonthScrollViewInner({
onLongPressDate, onLongPressDate,
}: MonthScrollViewProps) { }: MonthScrollViewProps) {
const styles = useMonthStyles(); const styles = useMonthStyles();
const { locale } = useCalendarLocale(); const { locale, system } = useCalendarLocale();
const index = React.useMemo( const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events), () => eventsByDay ?? buildEventDayIndex(events),
[eventsByDay, 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 // 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. // 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 // The month in focus: set by navigation, then by what scrolls under the
// sample line. // sample line.
const [activeMonth, setActiveMonth] = React.useState(() => monthKeyOf(focus.date)); const [activeMonth, setActiveMonth] = React.useState(() => monthKeyOf(focus.date, system));
const activeMonthRef = React.useRef(activeMonth); 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); const onVisibleRef = React.useRef(onVisibleDateChange);
onVisibleRef.current = onVisibleDateChange; onVisibleRef.current = onVisibleDateChange;
const offsetRef = React.useRef(0); const offsetRef = React.useRef(0);
@@ -163,7 +171,7 @@ function MonthScrollViewInner({
if (!days) return; if (!days) return;
// Mid-week decides which month a row belongs to. // Mid-week decides which month a row belongs to.
const mid = days[3]; const mid = days[3];
const key = monthKeyOf(mid); const key = monthKeyOf(mid, systemRef.current);
if (key === activeMonthRef.current) return; if (key === activeMonthRef.current) return;
activeMonthRef.current = key; activeMonthRef.current = key;
setActiveMonth(key); setActiveMonth(key);
@@ -191,14 +199,14 @@ function MonthScrollViewInner({
handledNonceRef.current = focus.nonce; handledNonceRef.current = focus.nonce;
const row = monthFocusRow(window, focus.date, weeks.length, opts); const row = monthFocusRow(window, focus.date, weeks.length, opts);
const currentRow = Math.round(offsetRef.current / rowHeight); const currentRow = Math.round(offsetRef.current / rowHeight);
const key = monthKeyOf(focus.date); const key = monthKeyOf(focus.date, system);
activeMonthRef.current = key; activeMonthRef.current = key;
setActiveMonth(key); setActiveMonth(key);
listRef.current?.scrollToIndex({ listRef.current?.scrollToIndex({
index: row, index: row,
animated: Math.abs(row - currentRow) <= SMOOTH_SCROLL_ROWS, 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); const selectedKey = dayKey(selectedDate);
// Today on a clock in the calendar's time zone. // Today on a clock in the calendar's time zone.
@@ -209,7 +217,7 @@ function MonthScrollViewInner({
return ( return (
<MonthWeekRow <MonthWeekRow
days={days} days={days}
activeMask={monthMask(days, activeMonth)} activeMask={monthMask(days, activeMonth, system)}
selectedIndex={dayIndexIn(days, selectedDate)} selectedIndex={dayIndexIn(days, selectedDate)}
todayIndex={dayIndexIn(days, displayNow())} todayIndex={dayIndexIn(days, displayNow())}
index={index} index={index}
@@ -241,6 +249,7 @@ function MonthScrollViewInner({
currentUserEmails, currentUserEmails,
rowHeight, rowHeight,
locale, locale,
system,
styles, styles,
onSelectDate, onSelectDate,
onLongPressDate, onLongPressDate,
+16 -23
View File
@@ -2,12 +2,9 @@ import React from 'react';
import { View, Text, StyleSheet, Pressable, useWindowDimensions } from 'react-native'; import { View, Text, StyleSheet, Pressable, useWindowDimensions } from 'react-native';
import { import {
addDays, addDays,
endOfMonth,
endOfWeek,
format, format,
getISOWeek, getISOWeek,
getWeek, getWeek,
startOfMonth,
startOfWeek, startOfWeek,
type Locale, type Locale,
} from 'date-fns'; } from 'date-fns';
@@ -31,6 +28,7 @@ import { TINTED_BAR_WIDTH, eventBlockColors } from '../../lib/event-colors';
import { useCalendarLocale } from '../../lib/calendar-locale'; import { useCalendarLocale } from '../../lib/calendar-locale';
import { dayCellAccessibilityLabel } from '../../lib/calendar-a11y'; import { dayCellAccessibilityLabel } from '../../lib/calendar-a11y';
import { displayNow } from '../../lib/calendar-timezone'; import { displayNow } from '../../lib/calendar-timezone';
import { dayNumberLabel, isFirstOfMonth, monthGridRows, monthStartLabel } from '../../lib/calendar-system';
type WeekStart = 0 | 1 | 6; type WeekStart = 0 | 1 | 6;
@@ -155,7 +153,7 @@ function MonthWeekRowInner({
onLongPressDate, onLongPressDate,
}: MonthWeekRowProps) { }: MonthWeekRowProps) {
const c = useColors(); const c = useColors();
const { t } = useCalendarLocale(); const { t, system } = useCalendarLocale();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
// "Light event colors" (webmail calendarTintedEvents). // "Light event colors" (webmail calendarTintedEvents).
const tinted = useSettingsStore((s) => s.calendarTintedEvents); const tinted = useSettingsStore((s) => s.calendarTintedEvents);
@@ -198,7 +196,7 @@ function MonthWeekRowInner({
const maxVisible = showTimeInMonthView ? MAX_CHIPS : MAX_DOTS; const maxVisible = showTimeInMonthView ? MAX_CHIPS : MAX_DOTS;
const visible = dayEvents.slice(0, maxVisible); const visible = dayEvents.slice(0, maxVisible);
const overflow = Math.max(0, dayEvents.length - 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 ( return (
<Pressable <Pressable
@@ -235,7 +233,7 @@ function MonthWeekRowInner({
today && styles.todayText, today && styles.todayText,
selected && !today && styles.selectedText, selected && !today && styles.selectedText,
]}> ]}>
{format(d, 'd')} {dayNumberLabel(d, system)}
</Text> </Text>
</View> </View>
{dayEvents.length > 0 && !showTimeInMonthView && ( {dayEvents.length > 0 && !showTimeInMonthView && (
@@ -330,7 +328,7 @@ function FullWeekRow({
colors: ThemePalette; colors: ThemePalette;
}) { }) {
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const { t } = useCalendarLocale(); const { t, system } = useCalendarLocale();
// "Light event colors" (webmail calendarTintedEvents). // "Light event colors" (webmail calendarTintedEvents).
const tinted = useSettingsStore((s) => s.calendarTintedEvents); const tinted = useSettingsStore((s) => s.calendarTintedEvents);
const showTime = monthChipShowsTime(showTimeInMonthView, width / 7); const showTime = monthChipShowsTime(showTimeInMonthView, width / 7);
@@ -345,7 +343,9 @@ function FullWeekRow({
const dayEvents = eventsOnDayFromIndex(index, d); const dayEvents = eventsOnDayFromIndex(index, d);
const shown = fullCellChipCount(height, dayEvents.length); const shown = fullCellChipCount(height, dayEvents.length);
const overflow = dayEvents.length - shown; 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 ( return (
<Pressable <Pressable
key={i} key={i}
@@ -421,25 +421,18 @@ function MonthViewInner({
}: MonthViewProps) { }: MonthViewProps) {
const styles = useMonthStyles(); const styles = useMonthStyles();
const [gridHeight, setGridHeight] = React.useState(0); const [gridHeight, setGridHeight] = React.useState(0);
const { locale } = useCalendarLocale(); const { locale, system } = useCalendarLocale();
const index = React.useMemo( const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events), () => eventsByDay ?? buildEventDayIndex(events),
[eventsByDay, events], [eventsByDay, events],
); );
// The month grid as rows of 7 days. // The month grid as rows of 7 days, in the calendar system's months.
const rows = React.useMemo(() => { const rows = React.useMemo(
const calStart = startOfWeek(startOfMonth(currentDate), { weekStartsOn }); () => monthGridRows(currentDate, weekStartsOn, system),
const calEnd = endOfWeek(endOfMonth(currentDate), { weekStartsOn }); [currentDate, weekStartsOn, system],
const out: Date[][] = []; );
let day = calStart; const activeMonth = monthKeyOf(currentDate, system);
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);
// Today on a clock in the calendar's time zone. // Today on a clock in the calendar's time zone.
const today = displayNow(); const today = displayNow();
@@ -463,7 +456,7 @@ function MonthViewInner({
variant={fill ? 'full' : 'compact'} variant={fill ? 'full' : 'compact'}
height={fullRowHeight} height={fullRowHeight}
days={days} days={days}
activeMask={monthMask(days, activeMonth)} activeMask={monthMask(days, activeMonth, system)}
selectedIndex={dayIndexIn(days, selectedDate)} selectedIndex={dayIndexIn(days, selectedDate)}
todayIndex={dayIndexIn(days, today)} todayIndex={dayIndexIn(days, today)}
index={index} index={index}
+5 -2
View File
@@ -8,6 +8,7 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { Button } from '..'; import { Button } from '..';
import { useCalendarLocale } from '../../lib/calendar-locale'; import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDay } from '../../lib/calendar-system';
import { import {
EDITOR_FREQUENCIES, EDITOR_FREQUENCIES,
UNIT_LABEL_KEYS, UNIT_LABEL_KEYS,
@@ -113,7 +114,7 @@ function NumberInput({
export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) { export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale(); const { locale, t, system } = useCalendarLocale();
const [value, setValue] = React.useState<RecurrenceEditorValue>(() => const [value, setValue] = React.useState<RecurrenceEditorValue>(() =>
editorValueFromRule(rule, eventStart), editorValueFromRule(rule, eventStart),
@@ -245,7 +246,9 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
{value.endsMode === 'on' && ( {value.endsMode === 'on' && (
<Pressable style={[styles.selectButton, styles.selectFlex]} onPress={() => setShowUntilPicker(true)}> <Pressable style={[styles.selectButton, styles.selectFlex]} onPress={() => setShowUntilPicker(true)}>
<Text style={styles.selectText}> <Text style={styles.selectText}>
{format(parseISO(value.untilDate), 'MMM d, yyyy', { locale })} {system.jalali
? formatCalendarDay(parseISO(value.untilDate), locale, 'monthDayYear')
: format(parseISO(value.untilDate), 'MMM d, yyyy', { locale })}
</Text> </Text>
</Pressable> </Pressable>
)} )}
+4 -4
View File
@@ -42,7 +42,7 @@ import {
import { askBeforeDiscard } from '../../lib/calendar-editor-discard'; import { askBeforeDiscard } from '../../lib/calendar-editor-discard';
import { isWritableCalendar } from '../../lib/calendar-editability'; import { isWritableCalendar } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale'; 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 { useSheetDrag } from '../../lib/use-sheet-drag';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetInsets } from '../../lib/use-layout'; import { useSheetInsets } from '../../lib/use-layout';
@@ -233,8 +233,8 @@ export function TasksSheet({
if (!d || !task.due) return null; if (!d || !task.due) return null;
const hasTime = !task.showWithoutTime && !/^\d{4}-\d{2}-\d{2}$/.test(task.due); const hasTime = !task.showWithoutTime && !/^\d{4}-\d{2}-\d{2}$/.test(task.due);
return hasTime return hasTime
? `${formatCalendarDate(d, locale.code, 'weekdayMonthDay')} · ${format(d, timePattern(timeFormat), { locale })}` ? `${formatCalendarDay(d, locale, 'weekdayMonthDay')} · ${format(d, timePattern(timeFormat), { locale })}`
: formatCalendarDate(d, locale.code, 'weekdayMonthDay'); : formatCalendarDay(d, locale, 'weekdayMonthDay');
}; };
const priorityColor = (level: PriorityLevel): string => const priorityColor = (level: PriorityLevel): string =>
@@ -321,7 +321,7 @@ export function TasksSheet({
> >
<CalendarIcon size={14} color={editor.due ? c.primary : c.textMuted} /> <CalendarIcon size={14} color={editor.due ? c.primary : c.textMuted} />
<Text style={[styles.chipText, editor.due && styles.chipTextActive]}> <Text style={[styles.chipText, editor.due && styles.chipTextActive]}>
{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')}
</Text> </Text>
{editor.due && ( {editor.due && (
<Pressable <Pressable
@@ -21,6 +21,7 @@ import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store'; import { useSettingsStore } from '../../stores/settings-store';
import { useCalendarLocale } from '../../lib/calendar-locale'; import { useCalendarLocale } from '../../lib/calendar-locale';
import { dayNumberLabel } from '../../lib/calendar-system';
import { eventAccessibilityLabel } from '../../lib/calendar-a11y'; import { eventAccessibilityLabel } from '../../lib/calendar-a11y';
import { import {
buildEventDayIndex, buildEventDayIndex,
@@ -558,6 +559,7 @@ const DayHeaderCell = React.memo(function DayHeaderCell({
styles: GridStyles; styles: GridStyles;
onPress?: (date: Date) => void; onPress?: (date: Date) => void;
}) { }) {
const { system } = useCalendarLocale();
return ( return (
<Pressable style={[styles.dayHeaderCell, { left, width }]} onPress={() => onPress?.(day)}> <Pressable style={[styles.dayHeaderCell, { left, width }]} onPress={() => onPress?.(day)}>
<Text style={styles.dayHeaderWeekday} numberOfLines={1}> <Text style={styles.dayHeaderWeekday} numberOfLines={1}>
@@ -577,7 +579,7 @@ const DayHeaderCell = React.memo(function DayHeaderCell({
selected && !today && styles.dayHeaderNumberTextSelected, selected && !today && styles.dayHeaderNumberTextSelected,
]} ]}
> >
{format(day, 'd')} {dayNumberLabel(day, system)}
</Text> </Text>
</View> </View>
</Pressable> </Pressable>
+3 -2
View File
@@ -13,6 +13,7 @@ import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store'; import { useSettingsStore } from '../../stores/settings-store';
import { useCalendarLocale } from '../../lib/calendar-locale'; import { useCalendarLocale } from '../../lib/calendar-locale';
import { dayNumberLabel } from '../../lib/calendar-system';
import { eventAccessibilityLabel } from '../../lib/calendar-a11y'; import { eventAccessibilityLabel } from '../../lib/calendar-a11y';
import { import {
buildEventDayIndex, buildEventDayIndex,
@@ -70,7 +71,7 @@ function WeekViewInner({
// "Light event colors" (webmail calendarTintedEvents). // "Light event colors" (webmail calendarTintedEvents).
const tinted = useSettingsStore((s) => s.calendarTintedEvents); const tinted = useSettingsStore((s) => s.calendarTintedEvents);
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale(); const { locale, t, system } = useCalendarLocale();
const scrollRef = React.useRef<ScrollView>(null); const scrollRef = React.useRef<ScrollView>(null);
const index = React.useMemo( const index = React.useMemo(
@@ -171,7 +172,7 @@ function WeekViewInner({
selected && !today && styles.dayHeaderNumberTextSelected, selected && !today && styles.dayHeaderNumberTextSelected,
]} ]}
> >
{format(day, 'd')} {dayNumberLabel(day, system)}
</Text> </Text>
</View> </View>
</Pressable> </Pressable>
+153
View File
@@ -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\)/);
});
});
+5 -5
View File
@@ -3,7 +3,7 @@ import type { Locale } from 'date-fns';
import type { Calendar, CalendarEvent } from '../api/types'; import type { Calendar, CalendarEvent } from '../api/types';
import { getEventDisplayEndDate, getEventStartDate, getPrimaryCalendarId, timePattern } from './calendar-utils'; import { getEventDisplayEndDate, getEventStartDate, getPrimaryCalendarId, timePattern } from './calendar-utils';
import { isDeclinedByUser } from './calendar-participants'; import { isDeclinedByUser } from './calendar-participants';
import { formatCalendarDate } from './date-format'; import { formatCalendarDay } from './calendar-system';
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string; type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
@@ -27,7 +27,7 @@ export function participantRemoveAccessibilityLabel(attendee: { name?: string; e
/** "Monday, November 2, 2026, 3 events" for a month day cell. */ /** "Monday, November 2, 2026, 3 events" for a month day cell. */
export function dayCellAccessibilityLabel(day: Date, eventCount: number, t: Translate, locale: Locale): string { 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; if (eventCount === 0) return date;
return `${date}, ${t('calendar.a11y.event_count', '{count, plural, one {# event} other {# events}}', { count: eventCount })}`; 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 end = getEventDisplayEndDate(event);
const valid = (d: Date) => !isNaN(d.getTime()); const valid = (d: Date) => !isNaN(d.getTime());
if (valid(start)) { if (valid(start)) {
const date = formatCalendarDate(start, locale.code, 'longWeekdayMonthDayYear'); const date = formatCalendarDay(start, locale, 'longWeekdayMonthDayYear');
if (event.showWithoutTime) { if (event.showWithoutTime) {
const last = valid(end) && !isSameDay(start, end) 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')); parts.push(date + last, t('calendar.events.all_day', 'All day'));
} else { } else {
@@ -61,7 +61,7 @@ export function eventAccessibilityLabel(
if (valid(end) && end.getTime() > start.getTime()) { if (valid(end) && end.getTime() > start.getTime()) {
range += isSameDay(start, end) range += isSameDay(start, end)
? ` – ${format(end, time, { locale })}` ? ` – ${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); parts.push(date, range);
} }
+7 -3
View File
@@ -2,6 +2,7 @@ import type { Locale } from 'date-fns';
import { enUS } from 'date-fns/locale/en-US'; import { enUS } from 'date-fns/locale/en-US';
import { useLocaleStore, type TranslateFn } from '../stores/locale-store'; import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
import type { LocaleCode } from '../i18n'; 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" // 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 // 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; 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 code = useLocaleStore((s) => s.locale);
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
return { locale: getDateFnsLocale(code), t }; return { locale: getDateFnsLocale(code), t, system: calendarSystemFor(code) };
} }
+6 -5
View File
@@ -1,5 +1,6 @@
import { addDays, differenceInCalendarDays } from 'date-fns'; import { addDays, differenceInCalendarDays } from 'date-fns';
import { baseRange, type DayRange, type ScrollWindowOptions } from './calendar-scroll-window'; 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 * 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)); return Math.max(0, Math.min(rowCount - 1, row));
} }
/** Year and month as one comparable number. */ /** Year and month as one comparable number, in the calendar system's months. */
export function monthKeyOf(date: Date): number { export function monthKeyOf(date: Date, system: CalendarSystem = gregorianCalendar): number {
return date.getFullYear() * 12 + date.getMonth(); 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 * compare this instead of the month itself, so moving to another month only
* re-renders the rows whose shading changes. * 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; let mask = 0;
days.forEach((day, i) => { days.forEach((day, i) => {
if (monthKeyOf(day) === monthKey) mask |= 1 << i; if (monthKeyOf(day, system) === monthKey) mask |= 1 << i;
}); });
return mask; return mask;
} }
+4
View File
@@ -8,6 +8,7 @@ import {
subDays, subDays,
} from 'date-fns'; } from 'date-fns';
import { dayKey } from './calendar-utils'; import { dayKey } from './calendar-utils';
import { monthGridRange, type CalendarSystem } from './calendar-system';
/** /**
* The calendar views scroll freely (#759; webmail lib/calendar-scroll-window): * The calendar views scroll freely (#759; webmail lib/calendar-scroll-window):
@@ -50,6 +51,8 @@ export interface CalendarFocus {
export interface ScrollWindowOptions { export interface ScrollWindowOptions {
weekStartsOn: WeekStartsOn; 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. */ /** 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 })), end: startOfDay(endOfWeek(day, { weekStartsOn: opts.weekStartsOn })),
}; };
case 'month': case 'month':
if (opts.calendarSystem) return monthGridRange(day, opts.weekStartsOn, opts.calendarSystem);
return { return {
start: startOfWeek(startOfMonth(day), { weekStartsOn: opts.weekStartsOn }), start: startOfWeek(startOfMonth(day), { weekStartsOn: opts.weekStartsOn }),
end: startOfDay(endOfWeek(endOfMonth(day), { weekStartsOn: opts.weekStartsOn })), end: startOfDay(endOfWeek(endOfMonth(day), { weekStartsOn: opts.weekStartsOn })),
+198
View File
@@ -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}`;
}
}
+137
View File
@@ -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[] = [
'فروردین',
'اردیبهشت',
'خرداد',
'تیر',
'مرداد',
'شهریور',
'مهر',
'آبان',
'آذر',
'دی',
'بهمن',
'اسفند',
];
+16 -17
View File
@@ -29,16 +29,14 @@ import {
Square, Square,
} from '../components/icons'; } from '../components/icons';
import { import {
format,
addDays, addDays,
subDays, subDays,
addMonths,
subMonths,
addWeeks, addWeeks,
subWeeks, subWeeks,
type Locale, type Locale,
} from 'date-fns'; } from 'date-fns';
import { useCalendarLocale } from '../lib/calendar-locale'; import { useCalendarLocale } from '../lib/calendar-locale';
import { dayNumberLabel, monthTitle, type CalendarSystem } from '../lib/calendar-system';
import { displayNow } from '../lib/calendar-timezone'; import { displayNow } from '../lib/calendar-timezone';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors'; import { useColors } from '../theme/colors';
@@ -153,10 +151,11 @@ type PendingAction =
// the next arrow press or edge usually finds them there already. // the next arrow press or edge usually finds them there already.
const RANGE_MARGIN_DAYS = 14; const RANGE_MARGIN_DAYS = 14;
// The month on screen; the year only when it isn't this year. // The month on screen; the year only when it isn't this year. Jalali
function headerTitle(date: Date, locale: Locale): string { // months and years in Persian, as the webmail's toolbar shows them.
const sameYear = date.getFullYear() === displayNow().getFullYear(); export function headerTitle(date: Date, locale: Locale, system: CalendarSystem): string {
return format(date, sameYear ? 'LLLL' : 'LLLL yyyy', { locale }); const sameYear = system.year(date) === system.year(displayNow());
return monthTitle(date, system, locale, !sameYear);
} }
export default function CalendarScreen() { export default function CalendarScreen() {
@@ -165,7 +164,7 @@ export default function CalendarScreen() {
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
const currentUserEmails = useUserCalendarAddresses(); const currentUserEmails = useUserCalendarAddresses();
const resolveContactName = useContactNameResolver(); const resolveContactName = useContactNameResolver();
const { locale } = useCalendarLocale(); const { locale, system } = useCalendarLocale();
const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView); const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView);
const calendarShowTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth); const calendarShowTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth);
const showTasksOnCalendar = useSettingsStore((s) => s.showTasksOnCalendar); 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 // shows exactly one period. Navigation moves the focus and, when that
// leaves the window, starts a fresh window there. // leaves the window, starts a fresh window there.
const windowOptions = React.useMemo<ScrollWindowOptions>( const windowOptions = React.useMemo<ScrollWindowOptions>(
() => ({ weekStartsOn: calendarFirstDayOfWeek }), () => ({ weekStartsOn: calendarFirstDayOfWeek, calendarSystem: system }),
[calendarFirstDayOfWeek], [calendarFirstDayOfWeek, system],
); );
const [focus, setFocus] = React.useState<CalendarFocus>(() => ({ date: displayNow(), nonce: 0 })); const [focus, setFocus] = React.useState<CalendarFocus>(() => ({ date: displayNow(), nonce: 0 }));
// The day the scrolled view shows at its top, while it differs from the focus. // 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 === 'week' ? subWeeks(base, 1)
: viewMode === '3day' ? subDays(base, multiDays) : viewMode === '3day' ? subDays(base, multiDays)
: viewMode === 'day' ? subDays(base, 1) : 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 goNext = React.useCallback(() => {
const base = visibleDate ?? focus.date; const base = visibleDate ?? focus.date;
@@ -511,9 +510,9 @@ export default function CalendarScreen() {
viewMode === 'week' ? addWeeks(base, 1) viewMode === 'week' ? addWeeks(base, 1)
: viewMode === '3day' ? addDays(base, multiDays) : viewMode === '3day' ? addDays(base, multiDays)
: viewMode === 'day' ? addDays(base, 1) : 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(() => { const goToday = React.useCallback(() => {
jumpTo(displayNow()); jumpTo(displayNow());
@@ -1038,11 +1037,11 @@ export default function CalendarScreen() {
style={styles.titleBtn} style={styles.titleBtn}
hitSlop={6} hitSlop={6}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={monthButtonAccessibilityLabel(headerTitle(titleDate, locale), t)} accessibilityLabel={monthButtonAccessibilityLabel(headerTitle(titleDate, locale, system), t)}
accessibilityState={{ expanded: monthPanelOpen }} accessibilityState={{ expanded: monthPanelOpen }}
> >
<Text style={styles.headerTitle} numberOfLines={1}> <Text style={styles.headerTitle} numberOfLines={1}>
{headerTitle(titleDate, locale)} {headerTitle(titleDate, locale, system)}
</Text> </Text>
<View style={monthPanelOpen && styles.chevronOpen}> <View style={monthPanelOpen && styles.chevronOpen}>
<ChevronDown size={18} color={c.textSecondary} /> <ChevronDown size={18} color={c.textSecondary} />
@@ -1095,7 +1094,7 @@ export default function CalendarScreen() {
accessibilityLabel={t('calendar.events.go_to_today', 'Go to today')} accessibilityLabel={t('calendar.events.go_to_today', 'Go to today')}
> >
<CalendarIcon size={26} color={c.text} strokeWidth={1.75} /> <CalendarIcon size={26} color={c.text} strokeWidth={1.75} />
<Text style={styles.todayIconText}>{format(displayNow(), 'd')}</Text> <Text style={styles.todayIconText}>{dayNumberLabel(displayNow(), system)}</Text>
</Pressable> </Pressable>
{enableCalendarTasks && ( {enableCalendarTasks && (
<Pressable <Pressable