feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)
* feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start - Add jalaali-js library for Gregorian ↔ Jalali date conversion - Create lib/jalali-utils.ts with Jalali calendar utilities - Create hooks/use-calendar-locale.ts for unified calendar locale handling - Expand FirstDayOfWeek type to include 6 (Saturday) - Update all calendar views (month, week, day, mini, toolbar) to support Jalali calendar display and Saturday-first week ordering - Add Jalali month names (Farvardin … Esfand) to all locale files - Add Persian (fa) locale with full translations - Update settings UI to include Saturday as first day of week option - Update useFormatEventDate to show Jalali dates when locale is fa - Auto-detect Jalali calendar when fa locale is active The calendar system automatically switches to Jalali when the locale is set to Persian (fa). All internal date handling remains Gregorian (ISO 8601) for JMAP protocol compatibility; Jalali conversion is purely at the display layer. * Add PR template for Jalali calendar feature * chore: remove accidentally added PR template * fix: add image_too_large key to fa locale for PR #462 compatibility
This commit is contained in:
@@ -1,11 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useCallback, type DragEvent } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
eachDayOfInterval, isSameDay, isSameMonth, isToday, format, parseISO,
|
||||
} from "date-fns";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EventCard } from "./event-card";
|
||||
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
@@ -14,6 +11,7 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import type { PendingEventPreview } from "./event-modal";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
|
||||
interface CalendarMonthViewProps {
|
||||
selectedDate: Date;
|
||||
@@ -47,16 +45,21 @@ export function CalendarMonthView({
|
||||
pendingPreview,
|
||||
}: CalendarMonthViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
||||
const {
|
||||
weekStartsOn,
|
||||
dayHeaderKeys,
|
||||
getMonthGridDays,
|
||||
checkIsToday,
|
||||
checkIsSameMonth,
|
||||
checkIsSameDay,
|
||||
formatDayNumber,
|
||||
formatFullDate,
|
||||
} = useCalendarLocale();
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(selectedDate);
|
||||
const monthEnd = endOfMonth(selectedDate);
|
||||
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
|
||||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
|
||||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
||||
}, [selectedDate, weekStart]);
|
||||
const days = useMemo(
|
||||
() => getMonthGridDays(selectedDate),
|
||||
[selectedDate, getMonthGridDays],
|
||||
);
|
||||
|
||||
const calendarMap = useMemo(() => {
|
||||
const map = new Map<string, Calendar>();
|
||||
@@ -83,10 +86,6 @@ export function CalendarMonthView({
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
const dayHeaders = firstDayOfWeek === 0
|
||||
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
|
||||
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const result: Date[][] = [];
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
@@ -141,9 +140,9 @@ export function CalendarMonthView({
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}>
|
||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={formatFullDate(selectedDate)}>
|
||||
<div className="grid grid-cols-7 border-b border-border" role="row">
|
||||
{dayHeaders.map((d) => (
|
||||
{dayHeaderKeys.map((d) => (
|
||||
<div key={d} role="columnheader" className={cn(
|
||||
"text-center text-xs font-medium text-muted-foreground py-2 border-e border-border last:border-e-0",
|
||||
isMobile && "py-1.5 text-[11px]"
|
||||
@@ -161,12 +160,12 @@ export function CalendarMonthView({
|
||||
)} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}>
|
||||
<div className="grid grid-cols-7 h-full">
|
||||
{week.map((day) => {
|
||||
const inMonth = isSameMonth(day, selectedDate);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
const today = isToday(day);
|
||||
const inMonth = checkIsSameMonth(day, selectedDate);
|
||||
const selected = checkIsSameDay(day, selectedDate);
|
||||
const today = checkIsToday(day);
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const dayEvents = eventsByDate.get(key) || [];
|
||||
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||
const fullDateLabel = formatFullDate(day);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -199,7 +198,7 @@ export function CalendarMonthView({
|
||||
inMonth && !selected && !today && "font-medium"
|
||||
)}
|
||||
>
|
||||
{format(day, "d")}
|
||||
{formatDayNumber(day)}
|
||||
</span>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
@@ -219,7 +218,7 @@ export function CalendarMonthView({
|
||||
{dayEvents.length > 3 && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
||||
)}
|
||||
{pendingPreview && isSameDay(pendingPreview.start, day) && (
|
||||
{pendingPreview && checkIsSameDay(pendingPreview.start, day) && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full border border-dashed"
|
||||
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
|
||||
@@ -233,7 +232,7 @@ export function CalendarMonthView({
|
||||
</div>
|
||||
|
||||
{!isMobile && pendingPreview && (() => {
|
||||
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
|
||||
const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start));
|
||||
if (previewDayIdx === -1) return null;
|
||||
const previewRow = rowCount;
|
||||
const cal = calendarMap.get(pendingPreview.calendarId);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
|
||||
import { addDays, startOfWeek } from "date-fns";
|
||||
import { startOfWeek } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarViewMode } from "@/stores/calendar-store";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
|
||||
interface CalendarToolbarProps {
|
||||
selectedDate: Date;
|
||||
@@ -50,7 +51,14 @@ export function CalendarToolbar({
|
||||
onMenuClick,
|
||||
}: CalendarToolbarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const formatter = useFormatter();
|
||||
const {
|
||||
weekStartsOn,
|
||||
formatMonthYear,
|
||||
formatMonthYearShort,
|
||||
formatWeekRange,
|
||||
formatWeekRangeShort,
|
||||
formatFullDate,
|
||||
} = useCalendarLocale();
|
||||
const views: CalendarViewMode[] = enableCalendarTasks
|
||||
? ["month", "week", "day", "agenda", "tasks"]
|
||||
: ["month", "week", "day", "agenda"];
|
||||
@@ -72,28 +80,22 @@ export function CalendarToolbar({
|
||||
switch (viewMode) {
|
||||
case "month":
|
||||
return isMobile
|
||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||||
? formatMonthYearShort(selectedDate)
|
||||
: formatMonthYear(selectedDate);
|
||||
case "week": {
|
||||
const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 });
|
||||
const we = addDays(ws, 6);
|
||||
if (isMobile) {
|
||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}`;
|
||||
}
|
||||
const sameMonth = ws.getMonth() === we.getMonth();
|
||||
if (sameMonth) {
|
||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`;
|
||||
}
|
||||
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`;
|
||||
const ws = startOfWeek(selectedDate, { weekStartsOn });
|
||||
return isMobile
|
||||
? formatWeekRangeShort(ws)
|
||||
: formatWeekRange(ws);
|
||||
}
|
||||
case "day":
|
||||
return isMobile
|
||||
? formatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
|
||||
: formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
||||
? formatFullDate(selectedDate)
|
||||
: formatFullDate(selectedDate);
|
||||
case "agenda":
|
||||
return isMobile
|
||||
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
|
||||
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
|
||||
? formatMonthYearShort(selectedDate)
|
||||
: formatMonthYear(selectedDate);
|
||||
case "tasks":
|
||||
return t("views.tasks");
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function CalendarWeekView({
|
||||
const intlFormatter = useFormatter();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
||||
const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6;
|
||||
|
||||
const weekDays = useMemo(() => {
|
||||
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, Fragment } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
addMonths, subMonths, addYears, subYears, setMonth, setYear,
|
||||
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
|
||||
isSameDay, isSameMonth, isToday, format,
|
||||
getISOWeek, getWeek, format,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getEventDayBounds } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
|
||||
|
||||
type PickerView = "days" | "months" | "years";
|
||||
|
||||
const MONTH_LABELS = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
interface MiniCalendarProps {
|
||||
selectedDate: Date;
|
||||
displayMonth: Date;
|
||||
@@ -40,17 +34,25 @@ export function MiniCalendar({
|
||||
showWeekNumbers = false,
|
||||
}: MiniCalendarProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
|
||||
const {
|
||||
weekStartsOn,
|
||||
dayHeaderKeys,
|
||||
getMonthGridDays,
|
||||
checkIsToday,
|
||||
checkIsSameMonth,
|
||||
checkIsSameDay,
|
||||
formatDayNumber,
|
||||
formatMonthYear,
|
||||
getMonth,
|
||||
getYear,
|
||||
monthLabelKeys,
|
||||
} = useCalendarLocale();
|
||||
const [pickerView, setPickerView] = useState<PickerView>("days");
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(displayMonth);
|
||||
const monthEnd = endOfMonth(displayMonth);
|
||||
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
|
||||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
|
||||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
||||
}, [displayMonth, weekStart]);
|
||||
const days = useMemo(
|
||||
() => getMonthGridDays(displayMonth),
|
||||
[displayMonth, getMonthGridDays],
|
||||
);
|
||||
|
||||
const eventDates = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
@@ -67,20 +69,16 @@ export function MiniCalendar({
|
||||
return set;
|
||||
}, [events]);
|
||||
|
||||
const dayHeaders = firstDayOfWeek === 0
|
||||
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
|
||||
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
|
||||
// Compute week numbers for each row (one per 7-day chunk)
|
||||
const weekNumbers = useMemo(() => {
|
||||
if (!showWeekNumbers) return [];
|
||||
const nums: number[] = [];
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
// Use the first day of each row to determine the week number
|
||||
nums.push(weekStart === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
|
||||
nums.push(weekStartsOn === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
|
||||
}
|
||||
return nums;
|
||||
}, [days, showWeekNumbers, weekStart]);
|
||||
}, [days, showWeekNumbers, weekStartsOn]);
|
||||
|
||||
const currentYear = getYear(displayMonth);
|
||||
const currentMonth = getMonth(displayMonth);
|
||||
@@ -116,7 +114,7 @@ export function MiniCalendar({
|
||||
|
||||
const headerLabel =
|
||||
pickerView === "days"
|
||||
? intlFormatter.dateTime(displayMonth, { month: "long", year: "numeric" })
|
||||
? formatMonthYear(displayMonth)
|
||||
: pickerView === "months"
|
||||
? String(currentYear)
|
||||
: `${decadeStart}\u2013${decadeStart + 9}`;
|
||||
@@ -160,15 +158,15 @@ export function MiniCalendar({
|
||||
{showWeekNumbers && (
|
||||
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
|
||||
)}
|
||||
{dayHeaders.map((d) => (
|
||||
{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 = isSameMonth(day, displayMonth);
|
||||
const selected = isSameDay(day, selectedDate);
|
||||
const today = isToday(day);
|
||||
const inMonth = checkIsSameMonth(day, displayMonth);
|
||||
const selected = checkIsSameDay(day, selectedDate);
|
||||
const today = checkIsToday(day);
|
||||
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
|
||||
const isFirstDayOfRow = index % 7 === 0;
|
||||
|
||||
@@ -193,7 +191,7 @@ export function MiniCalendar({
|
||||
selected && "bg-primary text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
{format(day, "d")}
|
||||
{formatDayNumber(day)}
|
||||
{hasEvent && !selected && (
|
||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
|
||||
)}
|
||||
@@ -206,7 +204,7 @@ export function MiniCalendar({
|
||||
|
||||
{pickerView === "months" && (
|
||||
<div className="grid grid-cols-3 gap-1 py-1">
|
||||
{MONTH_LABELS.map((label, i) => {
|
||||
{monthLabelKeys.map((labelKey, i) => {
|
||||
const isCurrentMonth = i === currentMonth && currentYear === getYear(new Date());
|
||||
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
|
||||
return (
|
||||
@@ -220,7 +218,7 @@ export function MiniCalendar({
|
||||
!isSelected && !isCurrentMonth && "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{t(`months.${labelKey}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -104,6 +104,7 @@ export function LanguageSettings() {
|
||||
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as FirstDayOfWeek)}
|
||||
options={[
|
||||
{ value: '1', label: tDays('monday') },
|
||||
{ value: '6', label: tDays('saturday') },
|
||||
{ value: '0', label: tDays('sunday') },
|
||||
]}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user