Files
SRCmail/components/calendar/mini-calendar.tsx
T
Hamed FallahandGitHub e10fced28a feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)
* feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start

- Add jalaali-js library for Gregorian ↔ Jalali date conversion
- Create lib/jalali-utils.ts with Jalali calendar utilities
- Create hooks/use-calendar-locale.ts for unified calendar locale handling
- Expand FirstDayOfWeek type to include 6 (Saturday)
- Update all calendar views (month, week, day, mini, toolbar) to support
  Jalali calendar display and Saturday-first week ordering
- Add Jalali month names (Farvardin … Esfand) to all locale files
- Add Persian (fa) locale with full translations
- Update settings UI to include Saturday as first day of week option
- Update useFormatEventDate to show Jalali dates when locale is fa
- Auto-detect Jalali calendar when fa locale is active

The calendar system automatically switches to Jalali when the locale is
set to Persian (fa). All internal date handling remains Gregorian (ISO
8601) for JMAP protocol compatibility; Jalali conversion is purely at
the display layer.

* Add PR template for Jalali calendar feature

* chore: remove accidentally added PR template

* fix: add image_too_large key to fa locale for PR #462 compatibility
2026-07-08 15:42:42 +02:00

255 lines
8.8 KiB
TypeScript

"use client";
import { useState, useMemo, Fragment } from "react";
import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
import {
addMonths, subMonths, addYears, subYears, setMonth, setYear,
getISOWeek, getWeek, format,
} from "date-fns";
import { cn } from "@/lib/utils";
import { getEventDayBounds } from "@/lib/calendar-utils";
import type { CalendarEvent } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
type PickerView = "days" | "months" | "years";
interface MiniCalendarProps {
selectedDate: Date;
displayMonth: Date;
onSelectDate: (date: Date) => void;
onChangeMonth: (date: Date) => void;
events?: CalendarEvent[];
firstDayOfWeek?: number;
showWeekNumbers?: boolean;
}
export function MiniCalendar({
selectedDate,
displayMonth,
onSelectDate,
onChangeMonth,
events = [],
firstDayOfWeek = 1,
showWeekNumbers = false,
}: MiniCalendarProps) {
const t = useTranslations("calendar");
const {
weekStartsOn,
dayHeaderKeys,
getMonthGridDays,
checkIsToday,
checkIsSameMonth,
checkIsSameDay,
formatDayNumber,
formatMonthYear,
getMonth,
getYear,
monthLabelKeys,
} = useCalendarLocale();
const [pickerView, setPickerView] = useState<PickerView>("days");
const days = useMemo(
() => getMonthGridDays(displayMonth),
[displayMonth, getMonthGridDays],
);
const eventDates = useMemo(() => {
const set = new Set<string>();
events.forEach(e => {
try {
const { startDay, endDay } = getEventDayBounds(e);
const cursor = new Date(startDay);
while (cursor <= endDay) {
set.add(format(cursor, "yyyy-MM-dd"));
cursor.setDate(cursor.getDate() + 1);
}
} catch { /* skip */ }
});
return set;
}, [events]);
// Compute week numbers for each row (one per 7-day chunk)
const weekNumbers = useMemo(() => {
if (!showWeekNumbers) return [];
const nums: number[] = [];
for (let i = 0; i < days.length; i += 7) {
// Use the first day of each row to determine the week number
nums.push(weekStartsOn === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
}
return nums;
}, [days, showWeekNumbers, weekStartsOn]);
const currentYear = getYear(displayMonth);
const currentMonth = getMonth(displayMonth);
const decadeStart = Math.floor(currentYear / 10) * 10;
const years = Array.from({ length: 12 }, (_, i) => decadeStart - 1 + i);
const handlePickMonth = (month: number) => {
onChangeMonth(setMonth(displayMonth, month));
setPickerView("days");
};
const handlePickYear = (year: number) => {
onChangeMonth(setYear(displayMonth, year));
setPickerView("months");
};
const handlePrev = () => {
if (pickerView === "days") onChangeMonth(subMonths(displayMonth, 1));
else if (pickerView === "months") onChangeMonth(subYears(displayMonth, 1));
else onChangeMonth(setYear(displayMonth, decadeStart - 10));
};
const handleNext = () => {
if (pickerView === "days") onChangeMonth(addMonths(displayMonth, 1));
else if (pickerView === "months") onChangeMonth(addYears(displayMonth, 1));
else onChangeMonth(setYear(displayMonth, decadeStart + 10));
};
const handleHeaderClick = () => {
if (pickerView === "days") setPickerView("months");
else if (pickerView === "months") setPickerView("years");
};
const headerLabel =
pickerView === "days"
? formatMonthYear(displayMonth)
: pickerView === "months"
? String(currentYear)
: `${decadeStart}\u2013${decadeStart + 9}`;
return (
<div className="select-none">
<div className="flex items-center justify-between mb-2">
<button
onClick={handlePrev}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label={t("nav_prev")}
>
<ChevronLeft className="w-4 h-4 text-muted-foreground" />
</button>
<button
onClick={handleHeaderClick}
disabled={pickerView === "years"}
title={pickerView !== "years" ? t("mini_calendar_change") : undefined}
className={cn(
"text-sm font-medium px-2 py-1 rounded-md transition-colors inline-flex items-center gap-1",
pickerView !== "years" && "hover:bg-muted cursor-pointer",
pickerView === "years" && "cursor-default"
)}
>
{headerLabel}
{pickerView !== "years" && (
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
<button
onClick={handleNext}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label={t("nav_next")}
>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{pickerView === "days" && (
<div className={cn("grid gap-0", showWeekNumbers ? "grid-cols-[auto_repeat(7,1fr)]" : "grid-cols-7")}>
{showWeekNumbers && (
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
)}
{dayHeaderKeys.map((d) => (
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
{t(`days.${d}`)}
</div>
))}
{days.map((day, index) => {
const inMonth = checkIsSameMonth(day, displayMonth);
const selected = checkIsSameDay(day, selectedDate);
const today = checkIsToday(day);
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
const isFirstDayOfRow = index % 7 === 0;
return (
<Fragment key={day.toISOString()}>
{showWeekNumbers && isFirstDayOfRow && (
<div
key={`wk-${index}`}
className="flex items-center justify-center w-5 text-[9px] text-muted-foreground/60 font-medium"
>
{weekNumbers[index / 7]}
</div>
)}
<button
key={`day-${day.toISOString()}`}
onClick={() => onSelectDate(day)}
className={cn(
"relative flex items-center justify-center w-7 h-7 text-xs rounded-full transition-colors",
!inMonth && "text-muted-foreground/40",
inMonth && !selected && "hover:bg-muted",
today && !selected && "font-bold text-primary",
selected && "bg-primary text-primary-foreground"
)}
>
{formatDayNumber(day)}
{hasEvent && !selected && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
)}
</button>
</Fragment>
);
})}
</div>
)}
{pickerView === "months" && (
<div className="grid grid-cols-3 gap-1 py-1">
{monthLabelKeys.map((labelKey, i) => {
const isCurrentMonth = i === currentMonth && currentYear === getYear(new Date());
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
return (
<button
key={i}
onClick={() => handlePickMonth(i)}
className={cn(
"py-2 text-xs rounded-md transition-colors",
isSelected && "bg-primary text-primary-foreground",
!isSelected && isCurrentMonth && "font-bold text-primary",
!isSelected && !isCurrentMonth && "hover:bg-muted"
)}
>
{t(`months.${labelKey}`)}
</button>
);
})}
</div>
)}
{pickerView === "years" && (
<div className="grid grid-cols-3 gap-1 py-1">
{years.map((year) => {
const inDecade = year >= decadeStart && year <= decadeStart + 9;
const isCurrentYear = year === getYear(new Date());
const isSelected = year === getYear(selectedDate);
return (
<button
key={year}
onClick={() => handlePickYear(year)}
className={cn(
"py-2 text-xs rounded-md transition-colors",
isSelected && "bg-primary text-primary-foreground",
!isSelected && isCurrentYear && "font-bold text-primary",
!isSelected && !isCurrentYear && !inDecade && "text-muted-foreground/40",
!isSelected && !isCurrentYear && "hover:bg-muted"
)}
>
{year}
</button>
);
})}
</div>
)}
</div>
);
}