* 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
300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState, useCallback, type DragEvent } from "react";
|
|
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";
|
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
|
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;
|
|
events: CalendarEvent[];
|
|
calendars: Calendar[];
|
|
onSelectDate: (date: Date) => void;
|
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
|
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
|
onHoverLeave?: () => void;
|
|
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
|
|
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
|
|
onCreateAtTime?: (date: Date) => void;
|
|
firstDayOfWeek?: number;
|
|
isMobile?: boolean;
|
|
pendingPreview?: PendingEventPreview | null;
|
|
}
|
|
|
|
export function CalendarMonthView({
|
|
selectedDate,
|
|
events,
|
|
calendars,
|
|
onSelectDate,
|
|
onSelectEvent,
|
|
onHoverEvent,
|
|
onHoverLeave,
|
|
onContextMenuEvent,
|
|
onContextMenuEmpty,
|
|
onCreateAtTime,
|
|
firstDayOfWeek = 1,
|
|
isMobile,
|
|
pendingPreview,
|
|
}: CalendarMonthViewProps) {
|
|
const t = useTranslations("calendar");
|
|
const {
|
|
weekStartsOn,
|
|
dayHeaderKeys,
|
|
getMonthGridDays,
|
|
checkIsToday,
|
|
checkIsSameMonth,
|
|
checkIsSameDay,
|
|
formatDayNumber,
|
|
formatFullDate,
|
|
} = useCalendarLocale();
|
|
|
|
const days = useMemo(
|
|
() => getMonthGridDays(selectedDate),
|
|
[selectedDate, getMonthGridDays],
|
|
);
|
|
|
|
const calendarMap = useMemo(() => {
|
|
const map = new Map<string, Calendar>();
|
|
calendars.forEach((c) => map.set(c.id, c));
|
|
return map;
|
|
}, [calendars]);
|
|
|
|
const eventsByDate = useMemo(() => {
|
|
const map = new Map<string, CalendarEvent[]>();
|
|
events.forEach((e) => {
|
|
try {
|
|
const { startDay, endDay } = getEventDayBounds(e);
|
|
|
|
const cursor = new Date(startDay);
|
|
while (cursor <= endDay) {
|
|
const key = format(cursor, "yyyy-MM-dd");
|
|
const arr = map.get(key) || [];
|
|
arr.push(e);
|
|
map.set(key, arr);
|
|
cursor.setDate(cursor.getDate() + 1);
|
|
}
|
|
} catch { /* skip invalid dates */ }
|
|
});
|
|
return map;
|
|
}, [events]);
|
|
|
|
const weeks = useMemo(() => {
|
|
const result: Date[][] = [];
|
|
for (let i = 0; i < days.length; i += 7) {
|
|
result.push(days.slice(i, i + 7));
|
|
}
|
|
return result;
|
|
}, [days]);
|
|
|
|
const weekSegments = useMemo(() => {
|
|
return weeks.map((week) => {
|
|
const segments = buildWeekSegments(events, week);
|
|
const rowCount = segments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
|
|
return { week, segments, rowCount };
|
|
});
|
|
}, [events, weeks]);
|
|
|
|
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
|
|
|
|
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
|
|
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
setDropDayKey((prev) => prev === dayKey ? prev : dayKey);
|
|
}, []);
|
|
|
|
const handleCellDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
const related = e.relatedTarget as Node | null;
|
|
if (!e.currentTarget.contains(related)) setDropDayKey(null);
|
|
}, []);
|
|
|
|
const handleCellDrop = useCallback(async (e: DragEvent<HTMLDivElement>, day: Date) => {
|
|
e.preventDefault();
|
|
setDropDayKey(null);
|
|
const json = e.dataTransfer.getData("application/x-calendar-event");
|
|
if (!json) return;
|
|
try {
|
|
const data = JSON.parse(json);
|
|
const originalStart = parseISO(data.originalStart);
|
|
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
|
|
const isAllDay = event?.showWithoutTime;
|
|
const newStart = new Date(day);
|
|
newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0);
|
|
const newStartISO = isAllDay ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
|
if (newStartISO === data.originalStart) return;
|
|
const client = useAuthStore.getState().client;
|
|
if (!client) return;
|
|
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
|
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
|
} catch {
|
|
toast.error(t("notifications.event_move_error"));
|
|
}
|
|
}, [t]);
|
|
|
|
return (
|
|
<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">
|
|
{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]"
|
|
)}>
|
|
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex-1 flex flex-col overflow-y-auto">
|
|
{weekSegments.map(({ week, segments, rowCount }, wi) => (
|
|
<div key={wi} className={cn(
|
|
"relative flex-1 border-b border-border last:border-b-0",
|
|
isMobile ? "min-h-[52px]" : "min-h-[100px]"
|
|
)} 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 = 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 = formatFullDate(day);
|
|
|
|
return (
|
|
<div
|
|
key={key}
|
|
role="gridcell"
|
|
aria-selected={selected}
|
|
aria-label={fullDateLabel}
|
|
onClick={() => onSelectDate(day)}
|
|
onDoubleClick={() => onCreateAtTime?.(day)}
|
|
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
|
|
onDragOver={(e) => handleCellDragOver(e, key)}
|
|
onDragLeave={handleCellDragLeave}
|
|
onDrop={(e) => handleCellDrop(e, day)}
|
|
className={cn(
|
|
"border-e border-border last:border-e-0 p-1 cursor-pointer transition-colors touch-manipulation",
|
|
!inMonth && "bg-muted/30",
|
|
"hover:bg-muted/50",
|
|
selected && isMobile && "bg-primary/10",
|
|
dropDayKey === key && "ring-2 ring-inset ring-primary bg-primary/10"
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-center mb-0.5">
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center justify-center rounded-full",
|
|
isMobile ? "w-7 h-7 text-xs" : "w-6 h-6 text-xs",
|
|
today && !selected && "bg-primary text-primary-foreground font-bold",
|
|
selected && "bg-primary text-primary-foreground font-bold",
|
|
!inMonth && !selected && !today && "text-muted-foreground/50",
|
|
inMonth && !selected && !today && "font-medium"
|
|
)}
|
|
>
|
|
{formatDayNumber(day)}
|
|
</span>
|
|
</div>
|
|
{isMobile ? (
|
|
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
|
{dayEvents.slice(0, 3).map((ev) => {
|
|
const calId = getPrimaryCalendarId(ev);
|
|
const cal = calId ? calendarMap.get(calId) : undefined;
|
|
const evColor = ev.color || cal?.color || "#3b82f6";
|
|
return (
|
|
<span
|
|
key={ev.id}
|
|
className="w-1.5 h-1.5 rounded-full"
|
|
style={{ backgroundColor: evColor }}
|
|
/>
|
|
);
|
|
})}
|
|
{dayEvents.length > 3 && (
|
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
|
)}
|
|
{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" }}
|
|
/>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{!isMobile && pendingPreview && (() => {
|
|
const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start));
|
|
if (previewDayIdx === -1) return null;
|
|
const previewRow = rowCount;
|
|
const cal = calendarMap.get(pendingPreview.calendarId);
|
|
const color = cal?.color || "#3b82f6";
|
|
return (
|
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
|
<div
|
|
className="absolute px-0.5"
|
|
style={{
|
|
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
|
|
width: `calc(${(1 / 7) * 100}% - 2px)`,
|
|
top: previewRow * 22,
|
|
height: 20,
|
|
}}
|
|
>
|
|
<div
|
|
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate border-2 border-dashed"
|
|
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
|
|
>
|
|
{pendingPreview.title}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{!isMobile && segments.length > 0 && (
|
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
|
{segments.map((segment) => {
|
|
const calId = getPrimaryCalendarId(segment.event);
|
|
return (
|
|
<div
|
|
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
|
|
className="absolute px-0.5 pointer-events-auto"
|
|
style={{
|
|
left: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
|
|
width: `calc(${(segment.span / 7) * 100}% - 2px)`,
|
|
top: segment.row * 22,
|
|
height: 20,
|
|
}}
|
|
>
|
|
<EventCard
|
|
event={segment.event}
|
|
calendar={calId ? calendarMap.get(calId) : undefined}
|
|
variant="span"
|
|
continuesBefore={segment.continuesBefore}
|
|
continuesAfter={segment.continuesAfter}
|
|
onClick={(rect) => onSelectEvent(segment.event, rect)}
|
|
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
|
|
onMouseLeave={onHoverLeave}
|
|
onContextMenu={onContextMenuEvent}
|
|
draggable
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|