diff --git a/src/layouts/widgets/calendar/calendar.tsx b/src/layouts/widgets/calendar/calendar.tsx index bd022497..5441b488 100644 --- a/src/layouts/widgets/calendar/calendar.tsx +++ b/src/layouts/widgets/calendar/calendar.tsx @@ -4,6 +4,7 @@ import { WidgetContainer } from '../widget-container' import { CalendarGrid } from './components/calendar-grid' import { CalendarHeader } from './components/calendar-header' import { GoogleCalendarView } from './components/google-calendar/google-calendar-view' +import { DateConverterView } from './components/date-converter/date-converter-view' import Analytics from '@/analytics' import { Icon } from '@/src/icons' @@ -31,6 +32,19 @@ const CalendarTabSelector: React.FC = ({ تقویم + + + ))} + + +
+
+ روز + +
+
+ ماه + +
+
+ سال + +
+
+ +
+ + +
+ + {converted && ( +
+
+ +
+ {source !== 'shamsi' && ( +
+ + شمسی: + + {converted.shamsi} + +
+ )} + {source !== 'gregorian' && ( +
+ + میلادی: + + {converted.gregorian} + +
+ )} + {source !== 'hijri' && ( +
+ + قمری: + + {converted.hijri} + +
+ )} +
+
+
+ )} + + ) +} diff --git a/src/layouts/widgets/calendar/components/date-converter/date-converter.util.ts b/src/layouts/widgets/calendar/components/date-converter/date-converter.util.ts new file mode 100644 index 00000000..15358703 --- /dev/null +++ b/src/layouts/widgets/calendar/components/date-converter/date-converter.util.ts @@ -0,0 +1,215 @@ +import jalaliMoment from 'jalali-moment' +import hijriMoment from 'moment-hijri' +import { convertShamsiToHijri, hijriMonthNames, iranianHijriMonthDays } from '../../utils' + +export type CalendarType = 'gregorian' | 'shamsi' | 'hijri' + +export interface ConvertedDates { + shamsi: string + gregorian: string + hijri: string +} + +export const shamsiMonthNames = [ + 'فروردین', + 'اردیبهشت', + 'خرداد', + 'تیر', + 'مرداد', + 'شهریور', + 'مهر', + 'آبان', + 'آذر', + 'دی', + 'بهمن', + 'اسفند', +] + +export const gregorianMonthNames = [ + 'ژانویه', + 'فوریه', + 'مارس', + 'آوریل', + 'مه', + 'ژوئن', + 'ژوئیه', + 'اوت', + 'سپتامبر', + 'اکتبر', + 'نوامبر', + 'دسامبر', +] + +function convertHijriToShamsi( + year: number, + month: number, + day: number +): jalaliMoment.Moment | null { + const referenceShamsi = jalaliMoment + .from('1402/04/28', 'fa', 'YYYY/MM/DD') + .startOf('day') + const referenceYear = 1445 + const referenceMonth = 1 + const referenceDay = 1 + + // The official Iranian Hijri calendar data only covers 1445–1448. Dates + // outside that range (before or after the reference date) fall back to moment-hijri. + if (!iranianHijriMonthDays[year]) { + const hijriDate = hijriMoment(`${year}-${month}-${day + 1}`, 'iYYYY-iM-iD') + if (!hijriDate.isValid()) return null + return jalaliMoment(hijriDate.toDate()).locale('fa').startOf('day') + } + + let totalDays = 0 + + for (let y = referenceYear; y < year; y++) { + const yearDays = iranianHijriMonthDays[y] + if (!yearDays) return null + totalDays += Object.values(yearDays).reduce((a, b) => a + b, 0) + } + + for (let m = referenceMonth; m < month; m++) { + const monthDays = iranianHijriMonthDays[year]?.[m] + if (!monthDays) return null + totalDays += monthDays + } + + totalDays += day - referenceDay + + return referenceShamsi.clone().add(totalDays, 'days') +} + +function formatShamsi(date: jalaliMoment.Moment): string { + return `${date.jDate()} ${shamsiMonthNames[date.jMonth()]} ${date.jYear()}` +} + +function formatGregorian(date: Date): string { + return `${date.getDate()} ${gregorianMonthNames[date.getMonth()]} ${date.getFullYear()}` +} + +function formatHijri(date: hijriMoment.Moment): string { + const m = date.iMonth() + const d = date.iDate() + const y = date.iYear() + return `${d} ${hijriMonthNames[m]} ${y}` +} + +const SHAMSI_MONTH_DAYS = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29] + +export function getDaysInMonth( + source: CalendarType, + year: number, + month: number +): number { + switch (source) { + case 'shamsi': { + if (month === 12) { + const isLeap = jalaliMoment().jYear(year).jIsLeapYear() + return isLeap ? 30 : 29 + } + return SHAMSI_MONTH_DAYS[month - 1] + } + case 'gregorian': { + return new Date(year, month, 0).getDate() + } + case 'hijri': { + return iranianHijriMonthDays[year]?.[month] ?? 29 + } + } +} + +export function getMonthNames(source: CalendarType): string[] { + switch (source) { + case 'shamsi': + return shamsiMonthNames + case 'gregorian': + return gregorianMonthNames + case 'hijri': + return hijriMonthNames + } +} + +function getTodayHijri(): hijriMoment.Moment { + return convertShamsiToHijri(jalaliMoment().locale('fa')) +} + +export function getCurrentYear(source: CalendarType): number { + switch (source) { + case 'shamsi': + return jalaliMoment().locale('fa').jYear() + case 'gregorian': + return new Date().getFullYear() + case 'hijri': + return getTodayHijri().iYear() + } +} + +export function getCurrentMonth(source: CalendarType): number { + switch (source) { + case 'shamsi': + return jalaliMoment().locale('fa').jMonth() + 1 + case 'gregorian': + return new Date().getMonth() + 1 + case 'hijri': + return getTodayHijri().iMonth() + 1 + } +} + +export function getCurrentDay(source: CalendarType): number { + switch (source) { + case 'shamsi': + return jalaliMoment().locale('fa').jDate() + case 'gregorian': + return new Date().getDate() + case 'hijri': + return getTodayHijri().iDate() + } +} + +export function getYearRange(source: CalendarType): number[] { + const currentYear = getCurrentYear(source) + return Array.from({ length: 101 }, (_, i) => currentYear - 50 + i) +} + +export function convertDate( + source: CalendarType, + date: jalaliMoment.Moment +): ConvertedDates | null { + switch (source) { + case 'shamsi': { + const shamsiMoment = date + const gregDate = shamsiMoment.clone().locale('en').toDate() + const hijri = convertShamsiToHijri(shamsiMoment) + return { + shamsi: formatShamsi(shamsiMoment), + gregorian: formatGregorian(gregDate), + hijri: formatHijri(hijri), + } + } + case 'gregorian': { + const gregDate = date.clone().locale('en').toDate() + const shamsiMoment = jalaliMoment(gregDate).locale('fa') + const hijri = convertShamsiToHijri(shamsiMoment) + return { + shamsi: formatShamsi(shamsiMoment), + gregorian: formatGregorian(gregDate), + hijri: formatHijri(hijri), + } + } + case 'hijri': { + const hijriDate = date as unknown as hijriMoment.Moment + const hijriYear = hijriDate.iYear() + const hijriMonth = hijriDate.iMonth() + 1 + const hijriDay = hijriDate.iDate() + const shamsiMoment = convertHijriToShamsi(hijriYear, hijriMonth, hijriDay) + if (!shamsiMoment) return null + const gregDate = shamsiMoment.clone().locale('en').toDate() + const hijri = convertShamsiToHijri(shamsiMoment) + return { + shamsi: formatShamsi(shamsiMoment), + gregorian: formatGregorian(gregDate), + hijri: formatHijri(hijri), + } + } + } +} diff --git a/src/layouts/widgets/calendar/components/date-converter/scroll-wheel.tsx b/src/layouts/widgets/calendar/components/date-converter/scroll-wheel.tsx new file mode 100644 index 00000000..5d982156 --- /dev/null +++ b/src/layouts/widgets/calendar/components/date-converter/scroll-wheel.tsx @@ -0,0 +1,213 @@ +import { useRef, useEffect, useState, useCallback } from 'react' + +const ITEM_HEIGHT = 40 +// Minimum cumulative pointer movement (px) before a mousedown+mouseup is treated +// as a drag rather than a click. Below this, it's just an imprecise click. +const DRAG_CLICK_THRESHOLD = 4 + +interface ScrollWheelProps { + items: (string | number)[] + value: string | number + onChange: (value: string | number) => void +} + +export function ScrollWheel({ items, value, onChange }: ScrollWheelProps) { + const containerRef = useRef(null) + const rafRef = useRef(null) + const scrollEndTimeoutRef = useRef | null>(null) + const dragRef = useRef({ + active: false, + startY: 0, + startScrollTop: 0, + }) + const suppressClickRef = useRef(false) + const isMountedRef = useRef(true) + + const [activeIndex, setActiveIndex] = useState(() => { + const index = items.indexOf(value) + return index < 0 ? 0 : index + }) + + const getClampedIndex = useCallback( + (scrollTop: number): number => { + return Math.max( + 0, + Math.min(Math.round(scrollTop / ITEM_HEIGHT), items.length - 1) + ) + }, + [items.length] + ) + + const updateActiveIndex = useCallback( + (scrollTop: number) => { + const index = getClampedIndex(scrollTop) + setActiveIndex((prev) => (prev === index ? prev : index)) + }, + [getClampedIndex] + ) + + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + } + }, []) + + useEffect(() => { + if (!isMountedRef.current) return + const container = containerRef.current + if (!container) return + + const index = items.indexOf(value) + if (index < 0) return + + const targetScroll = index * ITEM_HEIGHT + if (container.scrollTop !== targetScroll) { + container.scrollTo({ top: targetScroll, behavior: 'smooth' }) + } + }, [value, items.length]) + + const snapToNearest = useCallback(() => { + const container = containerRef.current + if (!container) return + + const index = getClampedIndex(container.scrollTop) + setActiveIndex(index) + onChange(items[index]) + container.scrollTo({ top: index * ITEM_HEIGHT, behavior: 'smooth' }) + }, [getClampedIndex, items, onChange]) + + const handleScroll = useCallback(() => { + const container = containerRef.current + if (!container) return + updateActiveIndex(container.scrollTop) + + // Wheel/trackpad scrolling doesn't have a single discrete "end" event like drag does (no mouseup) + // and doesn't land exactly on an item boundary, so we debounce: once scroll events stop arriving for a bit + // treat that as scroll-end, snap to the nearest item, and fire the same onChange path that click/drag already use + if (scrollEndTimeoutRef.current !== null) { + clearTimeout(scrollEndTimeoutRef.current) + } + scrollEndTimeoutRef.current = setTimeout(() => { + scrollEndTimeoutRef.current = null + if (!dragRef.current.active) { + snapToNearest() + } + }, 120) + }, [updateActiveIndex, snapToNearest]) + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + const container = containerRef.current + if (!container || dragRef.current.active) return + + dragRef.current = { + active: true, + startY: e.clientY, + startScrollTop: container.scrollTop, + } + suppressClickRef.current = false + + container.style.cursor = 'grabbing' + document.body.style.userSelect = 'none' + + const onMouseMove = (e: MouseEvent) => { + if (!dragRef.current.active || !containerRef.current) return + const dy = dragRef.current.startY - e.clientY + if (Math.abs(dy) > DRAG_CLICK_THRESHOLD) { + suppressClickRef.current = true + } + containerRef.current.scrollTop = dragRef.current.startScrollTop + dy + } + + const onMouseUp = () => { + dragRef.current.active = false + containerRef.current?.style.removeProperty('cursor') + document.body.style.userSelect = '' + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + + // The drag itself generates scroll events, which may have queued up a pending scroll-end snap. + // We're handling the snap ourselves right here, so cancel it to avoid a redundant duplicate firing shortly after. + if (scrollEndTimeoutRef.current !== null) { + clearTimeout(scrollEndTimeoutRef.current) + scrollEndTimeoutRef.current = null + } + + const container = containerRef.current + if (!container) return + + const index = getClampedIndex(container.scrollTop) + setActiveIndex(index) + onChange(items[index]) + container.scrollTo({ top: index * ITEM_HEIGHT, behavior: 'smooth' }) + } + + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + }, + [getClampedIndex, items] + ) + + const handleItemClick = useCallback( + (index: number) => { + if (suppressClickRef.current) { + suppressClickRef.current = false + return + } + setActiveIndex(index) + onChange(items[index]) + }, + [items] + ) + + useEffect(() => { + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current) + if (scrollEndTimeoutRef.current !== null) + clearTimeout(scrollEndTimeoutRef.current) + if (dragRef.current.active) { + dragRef.current.active = false + document.body.style.userSelect = '' + } + } + }, []) + + return ( +
+
+ +
e.stopPropagation()} + > +
+ {items.map((item, index) => ( +
handleItemClick(index)} + className="flex items-center justify-center transition-all" + style={{ height: `${ITEM_HEIGHT}px` }} + > + + {item} + +
+ ))} +
+
+ +
+
+
+ ) +} diff --git a/src/layouts/widgets/calendar/utils.ts b/src/layouts/widgets/calendar/utils.ts index f1074378..5e297e6a 100644 --- a/src/layouts/widgets/calendar/utils.ts +++ b/src/layouts/widgets/calendar/utils.ts @@ -12,7 +12,7 @@ export const hijriMonthNames = [ 'ربیع‌الأول', 'ربیع‌الثانی', 'جمادی الاول', - 'جمادی‌الثانی', + 'جمادی الثانی', 'رجب', 'شعبان', 'رمضان',