From 6416c61d243693686165dbe2c0e00f69915b815e Mon Sep 17 00:00:00 2001 From: Jaganath M S Date: Wed, 18 Feb 2026 22:19:22 +0530 Subject: [PATCH 001/160] feat(DayView): add day view and view switching capability - Add new DayView component with hourly time grid layout - Add MonthView component extracted from main Calendar for modularity - Implement view selector in Header to switch between month/week/day views - Extend CalendarContext to support view state management - Add CSS modules for DayView and MonthView components - Update type definitions to support CalendarViewType - Refactor Calendar component to conditionally render views based on state --- src/Calendar.tsx | 119 +++++++-------------------- src/common/Popover.tsx | 2 +- src/context/CalendarContext.tsx | 13 +-- src/layout/Header.tsx | 11 +++ src/types/index.ts | 3 +- src/views/day/DayView.module.css | 58 +++++++++++++ src/views/day/DayView.tsx | 80 ++++++++++++++++++ src/views/month/MonthView.module.css | 43 ++++++++++ src/views/month/MonthView.tsx | 108 ++++++++++++++++++++++++ 9 files changed, 342 insertions(+), 95 deletions(-) create mode 100644 src/views/day/DayView.module.css create mode 100644 src/views/day/DayView.tsx create mode 100644 src/views/month/MonthView.module.css create mode 100644 src/views/month/MonthView.tsx diff --git a/src/Calendar.tsx b/src/Calendar.tsx index 5502926..6ac2127 100644 --- a/src/Calendar.tsx +++ b/src/Calendar.tsx @@ -1,22 +1,12 @@ -import React, { useCallback, useMemo, memo, useEffect } from "react"; +import React, { useMemo, memo, useEffect } from "react"; import cx from "classnames"; import { CalendarType, CalendarContentType } from "./types"; -import { - DAY_LIST_NAME, - defaultCalenderProps, - CALENDAR_CONSTANTS, -} from "./constants"; -import { - dateFn, - convertToDate, - DateType, - generateCalendarGrid, - calculateMaxEvents, - useResizeObserver, -} from "./utils"; +import { defaultCalenderProps, CALENDAR_CONSTANTS } from "./constants"; +import { dateFn, useResizeObserver } from "./utils"; import styles from "./Calendar.module.css"; -import EventItem from "./common/EventItem"; import Header from "./layout/Header"; +import DayView from "./views/day/DayView"; +import MonthView from "./views/month/MonthView"; import { CalendarProvider, useCalendar } from "./context/CalendarContext"; function CalendarContent({ @@ -31,7 +21,7 @@ function CalendarContent({ ...restProps }: CalendarContentType) { const { state, dispatch } = useCalendar(); - const { currentDate: selectedDate, events: data } = state; + const { currentDate: selectedDate, events: data, view } = state; // Sync data from props to context useEffect(() => { @@ -40,40 +30,12 @@ function CalendarContent({ } }, [propsData]); - const calendarGrid = useMemo( - () => generateCalendarGrid(selectedDate, data), - [selectedDate, data], - ); - - const maxEvents = useMemo( - () => - restProps.maxEvents ?? - calculateMaxEvents( - height, - calendarGrid.length || CALENDAR_CONSTANTS.MIN_ROWS, - ), - [restProps.maxEvents, height, calendarGrid.length], - ); - - const onClickDateHandler = useCallback( - (dateInput: DateType) => { - const newDate = dateFn(dateInput); - onDateClick?.(convertToDate(newDate)); - if (isSelectDate && !newDate.isSame(selectedDate, "day")) { - dispatch({ type: "SET_DATE", payload: newDate }); - } - }, - [selectedDate, onDateClick], - ); - return (
- - - - {DAY_LIST_NAME[dayType].map((day: string) => ( - - ))} - - - - {calendarGrid.map((week, weekIndex) => ( - - {week.map((dayInfo, dayIndex) => ( - onMoreClick?.(convertToDate(d))} - /> - ))} - - ))} - -
- {day} -
+ {view === "day" ? ( + + ) : ( + + )}
); } @@ -150,7 +89,11 @@ function Calendar(props: CalendarType = defaultCalenderProps) { ); return ( - +
diff --git a/src/common/Popover.tsx b/src/common/Popover.tsx index babf34d..2818e59 100644 --- a/src/common/Popover.tsx +++ b/src/common/Popover.tsx @@ -14,7 +14,7 @@ import { isBeforeDate, isAfterDate, } from "../utils"; -import { CalendarType, DataTypeList, DateDataType } from "../types"; +import { CalendarType, DataTypeList } from "../types"; interface PopoverProps { dateObj: DateType; diff --git a/src/context/CalendarContext.tsx b/src/context/CalendarContext.tsx index ce0d269..d22b8de 100644 --- a/src/context/CalendarContext.tsx +++ b/src/context/CalendarContext.tsx @@ -6,18 +6,18 @@ import React, { useMemo, } from "react"; import { dateFn, DateType } from "../utils"; -import { DataType, CalendarView } from "../types"; +import { DataType, CalendarViewType } from "../types"; interface CalendarState { currentDate: DateType; selectedDate: DateType; - view: CalendarView; + view: CalendarViewType; events: DataType[]; } type CalendarAction = | { type: "SET_DATE"; payload: DateType } - | { type: "SET_VIEW"; payload: CalendarView } + | { type: "SET_VIEW"; payload: CalendarViewType } | { type: "SET_EVENTS"; payload: DataType[] } | { type: "NEXT" } | { type: "PREV" } @@ -82,18 +82,21 @@ interface CalendarProviderProps { children: ReactNode; initialEvents?: DataType[]; initialDate?: DateType; + initialView?: CalendarViewType; } export function CalendarProvider({ children, initialEvents = [], initialDate, + initialView = "month", }: CalendarProviderProps) { const [state, dispatch] = useReducer(calendarReducer, { ...initialState, events: initialEvents, currentDate: initialDate || initialState.currentDate, selectedDate: initialDate || initialState.selectedDate, + view: initialView, }); const value = useMemo(() => ({ state, dispatch }), [state]); @@ -105,10 +108,10 @@ export function CalendarProvider({ ); } -export const useCalendar = () => { +export function useCalendar() { const context = useContext(CalendarContext); if (context === undefined) { throw new Error("useCalendar must be used within a CalendarProvider"); } return context; -}; +} diff --git a/src/layout/Header.tsx b/src/layout/Header.tsx index d69d06a..2aff256 100644 --- a/src/layout/Header.tsx +++ b/src/layout/Header.tsx @@ -99,6 +99,17 @@ function Header({
+ - dispatch({ type: "SET_VIEW", payload: e.target.value as any }) - } + value={view} + onChange={onViewDropdownClick} > diff --git a/src/types/index.ts b/src/types/index.ts index 8359fc1..ff3142f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -45,6 +45,7 @@ export interface CalendarType { onEventClick?: (event: DataType) => void; onMoreClick?: (date: Date) => void; onMonthChange?: (date: Date) => void; + onViewChange?: (view: ECalendarViewType) => void; isSelectDate?: boolean; className?: string; headerClassName?: string; diff --git a/src/views/day/DayView.tsx b/src/views/day/DayView.tsx index 9b816a0..c018214 100644 --- a/src/views/day/DayView.tsx +++ b/src/views/day/DayView.tsx @@ -8,16 +8,18 @@ import { } from "../../utils"; import { DataType } from "../../types"; import styles from "./DayView.module.css"; +import { useCalendar } from "../../context/CalendarContext"; interface DayViewProps { - currentDate: DateType; events: DataType[]; onEventClick?: (event: DataType) => void; } const HOURS = Array.from({ length: 24 }, (_, i) => i); -function DayView({ currentDate, events, onEventClick }: DayViewProps) { +function DayView({ events, onEventClick }: DayViewProps) { + const { state } = useCalendar(); + const { currentDate } = state; const dayEvents = useMemo( () => calculateEventLayout(events, currentDate), [events, currentDate], diff --git a/src/views/month/MonthView.tsx b/src/views/month/MonthView.tsx index 6528f63..278bbbf 100644 --- a/src/views/month/MonthView.tsx +++ b/src/views/month/MonthView.tsx @@ -13,10 +13,7 @@ import styles from "./MonthView.module.css"; import EventItem from "../../common/event_item/EventItem"; import { useCalendar } from "../../context/CalendarContext"; -interface MonthViewProps extends Omit { - currentDate: DateType; - events: DataType[]; -} +interface MonthViewProps extends Omit {} function MonthView({ dayType, @@ -26,11 +23,11 @@ function MonthView({ onEventClick, onMoreClick, isSelectDate, - currentDate, events, ...restProps }: MonthViewProps) { - const { dispatch } = useCalendar(); + const { state, dispatch } = useCalendar(); + const { currentDate } = state; const calendarGrid = useMemo( () => generateCalendarGrid(currentDate, events), diff --git a/src/views/week/WeekView.tsx b/src/views/week/WeekView.tsx index 692f24c..964f36a 100644 --- a/src/views/week/WeekView.tsx +++ b/src/views/week/WeekView.tsx @@ -9,9 +9,9 @@ import { import { DataType, EDayType } from "../../types"; import { DAY_LIST_NAME } from "../../constants"; import styles from "./WeekView.module.css"; +import { useCalendar } from "../../context/CalendarContext"; interface WeekViewProps { - currentDate: DateType; events: DataType[]; onEventClick?: (event: DataType) => void; dayType?: EDayType; @@ -19,12 +19,9 @@ interface WeekViewProps { const HOURS = Array.from({ length: 24 }, (_, i) => i); -function WeekView({ - currentDate, - events, - onEventClick, - dayType = "HALF", -}: WeekViewProps) { +function WeekView({ events, onEventClick, dayType = "HALF" }: WeekViewProps) { + const { state } = useCalendar(); + const { currentDate } = state; const startOfWeek = useMemo(() => currentDate.startOf("week"), [currentDate]); const weekDays = useMemo(() => { From a37e498d9b9eab67b5e9de40e26b33191d25f715 Mon Sep 17 00:00:00 2001 From: Jaganath M S Date: Sat, 21 Feb 2026 12:48:05 +0530 Subject: [PATCH 012/160] refactor: extract reusable components for day/week views - Create TimeColumn component to display hour slots - Create DayColumn component to render event slots and layout - Create DayWeekEventItem component for day/week event rendering - Extract MonthEventItem into separate component with its own styles - Move shared CSS from view modules to component modules - Add generateTooltipText utility for consistent event tooltips - Update DayView and WeekView to use new components --- src/common/day_column/DayColumn.module.css | 4 ++ src/common/day_column/DayColumn.tsx | 27 ++++++++ .../DayWeekEventItem.module.css | 47 +++++++++++++ .../day_event_item/DayWeekEventItem.tsx | 49 +++++++++++++ .../MonthEventItem.module.css} | 0 .../MonthEventItem.tsx} | 14 ++-- src/common/time_column/TimeColumn.module.css | 15 ++++ src/common/time_column/TimeColumn.tsx | 19 +++++ src/utils/common.ts | 27 ++++++++ src/views/day/DayView.module.css | 67 ------------------ src/views/day/DayView.tsx | 61 ++-------------- src/views/month/MonthView.tsx | 4 +- src/views/week/WeekView.module.css | 69 ------------------- src/views/week/WeekView.tsx | 69 +++---------------- 14 files changed, 208 insertions(+), 264 deletions(-) create mode 100644 src/common/day_column/DayColumn.module.css create mode 100644 src/common/day_column/DayColumn.tsx create mode 100644 src/common/day_event_item/DayWeekEventItem.module.css create mode 100644 src/common/day_event_item/DayWeekEventItem.tsx rename src/common/{event_item/EventItem.module.css => month_event_item/MonthEventItem.module.css} (100%) rename src/common/{event_item/EventItem.tsx => month_event_item/MonthEventItem.tsx} (90%) create mode 100644 src/common/time_column/TimeColumn.module.css create mode 100644 src/common/time_column/TimeColumn.tsx diff --git a/src/common/day_column/DayColumn.module.css b/src/common/day_column/DayColumn.module.css new file mode 100644 index 0000000..c842113 --- /dev/null +++ b/src/common/day_column/DayColumn.module.css @@ -0,0 +1,4 @@ +.eventSlot { + height: 60px; /* Match timeSlot height */ + border-bottom: 1px solid #f0f0f0; +} diff --git a/src/common/day_column/DayColumn.tsx b/src/common/day_column/DayColumn.tsx new file mode 100644 index 0000000..0723eba --- /dev/null +++ b/src/common/day_column/DayColumn.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { DataType } from "../../types"; +import { DayEventLayout } from "../../utils/eventLayout"; +import { DayWeekEventItem } from "../day_event_item/DayWeekEventItem"; +import styles from "./DayColumn.module.css"; + +interface DayColumnProps { + dayEvents: DayEventLayout[]; + onEventClick?: (event: DataType) => void; +} + +const HOURS = Array.from({ length: 24 }, (_, i) => i); + +function DayColumn({ dayEvents, onEventClick }: DayColumnProps) { + return ( + <> + {HOURS.map((hour) => ( +
+ ))} + {dayEvents.map((item, index) => ( + + ))} + + ); +} + +export default DayColumn; diff --git a/src/common/day_event_item/DayWeekEventItem.module.css b/src/common/day_event_item/DayWeekEventItem.module.css new file mode 100644 index 0000000..a8bea01 --- /dev/null +++ b/src/common/day_event_item/DayWeekEventItem.module.css @@ -0,0 +1,47 @@ +.eventItem { + position: absolute; + left: 5px; + right: 5px; + padding: 4px 5px; + background-color: #3b82f6; + color: white; + border-radius: 4px; + font-size: 12px; + overflow: hidden; + cursor: pointer; + box-sizing: border-box; + width: calc(var(--event-width) - 2px); + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.eventItemSmall { + flex-direction: row; + align-items: center; + gap: 4px; + padding: 2px 5px; +} + +.eventItemTiny { + flex-direction: row; + align-items: center; + gap: 4px; + padding: 1px 4px; + font-size: 10px; + line-height: 1; +} + +.eventTitle { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.eventTime { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.9; +} diff --git a/src/common/day_event_item/DayWeekEventItem.tsx b/src/common/day_event_item/DayWeekEventItem.tsx new file mode 100644 index 0000000..8733556 --- /dev/null +++ b/src/common/day_event_item/DayWeekEventItem.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import cx from "classnames"; +import { formatDate, generateTooltipText } from "../../utils"; +import { DataType } from "../../types"; +import { DayEventLayout } from "../../utils/eventLayout"; +import styles from "./DayWeekEventItem.module.css"; + +interface DayWeekEventItemProps { + item: DayEventLayout; + onEventClick?: (event: DataType) => void; +} + +export function DayWeekEventItem({ + item, + onEventClick, +}: DayWeekEventItemProps) { + const eventColor = item.event.color || "#3b82f6"; + const tooltipText = generateTooltipText(item.event, "day"); + + const isSmall = item.height < 40 && item.height >= 20; + const isTiny = item.height < 20; + + return ( +
onEventClick?.(item.event)} + title={tooltipText} + > +
{item.event.value}
+
+ {formatDate(item.event.startDate, "HH:mm")} +
+
+ ); +} diff --git a/src/common/event_item/EventItem.module.css b/src/common/month_event_item/MonthEventItem.module.css similarity index 100% rename from src/common/event_item/EventItem.module.css rename to src/common/month_event_item/MonthEventItem.module.css diff --git a/src/common/event_item/EventItem.tsx b/src/common/month_event_item/MonthEventItem.tsx similarity index 90% rename from src/common/event_item/EventItem.tsx rename to src/common/month_event_item/MonthEventItem.tsx index 1af3dc6..e329910 100644 --- a/src/common/event_item/EventItem.tsx +++ b/src/common/month_event_item/MonthEventItem.tsx @@ -1,12 +1,12 @@ import React, { useState, useRef } from "react"; import cx from "classnames"; import { DataTypeList, DateDataType } from "../../types"; -import { formatDate, getDiffDays } from "../../utils"; -import styles from "./EventItem.module.css"; +import { formatDate, getDiffDays, generateTooltipText } from "../../utils"; +import styles from "./MonthEventItem.module.css"; import Popover from "../popover/Popover"; import { CALENDAR_CONSTANTS, defaultTheme } from "../../constants"; -function EventItem({ +function MonthEventItem({ date, dateObj, data, @@ -82,15 +82,11 @@ function EventItem({ } let diffDates = 1; - let tooltipText = formatDate(item.startDate, "YYYY-MM-DD"); if (item.endDateWeek) { diffDates = getDiffDays(item.endDateWeek, item.startDateWeek) + 1; } - if (item.endDate) { - tooltipText += ` to ${formatDate(item.endDate, "YYYY-MM-DD")}`; - } - tooltipText += ` - ${item.value}`; + const tooltipText = generateTooltipText(item, "month"); const width = `${cellWidth * diffDates - CALENDAR_CONSTANTS.EVENT_ITEM_PADDING}px`; return ( @@ -139,4 +135,4 @@ function EventItem({ ); } -export default EventItem; +export default MonthEventItem; diff --git a/src/common/time_column/TimeColumn.module.css b/src/common/time_column/TimeColumn.module.css new file mode 100644 index 0000000..809b946 --- /dev/null +++ b/src/common/time_column/TimeColumn.module.css @@ -0,0 +1,15 @@ +.timeColumn { + width: 60px; + flex-shrink: 0; + border-right: 1px solid #e0e0e0; +} + +.timeSlot { + height: 60px; /* 1 hour height */ + border-bottom: 1px solid #f0f0f0; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: #666; +} diff --git a/src/common/time_column/TimeColumn.tsx b/src/common/time_column/TimeColumn.tsx new file mode 100644 index 0000000..4b6cbf4 --- /dev/null +++ b/src/common/time_column/TimeColumn.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { dateFn, formatDate } from "../../utils"; +import styles from "./TimeColumn.module.css"; + +const HOURS = Array.from({ length: 24 }, (_, i) => i); + +function TimeColumn() { + return ( +
+ {HOURS.map((hour) => ( +
+ {formatDate(dateFn().hour(hour).minute(0), "HH:mm")} +
+ ))} +
+ ); +} + +export default TimeColumn; diff --git a/src/utils/common.ts b/src/utils/common.ts index cd994c5..5f94c97 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,4 +1,6 @@ import { CALENDAR_CONSTANTS } from "../constants"; +import { DataType, ECalendarViewType } from "../types"; +import { formatDate } from "./date"; /** * Calculates the maximum number of events that can be displayed in a cell based on the calendar height. @@ -15,3 +17,28 @@ export function calculateMaxEvents(height: number, rowsInView: number): number { return Math.max(0, calculatedMax); } + +/** + * Generates the tooltip text for an event based on the view type. + */ +export function generateTooltipText( + event: DataType, + viewType: ECalendarViewType, +): string { + if (viewType === ECalendarViewType.month) { + let tooltipText = formatDate(event.startDate, "YYYY-MM-DD"); + if (event.endDate) { + tooltipText += ` to ${formatDate(event.endDate, "YYYY-MM-DD")}`; + } + tooltipText += ` - ${event.value}`; + return tooltipText; + } + + // Day or Week view format + let tooltipText = `${event.value} (${formatDate(event.startDate, "HH:mm")}`; + if (event.endDate) { + tooltipText += ` - ${formatDate(event.endDate, "HH:mm")}`; + } + tooltipText += `)`; + return tooltipText; +} diff --git a/src/views/day/DayView.module.css b/src/views/day/DayView.module.css index b917589..6cf6f70 100644 --- a/src/views/day/DayView.module.css +++ b/src/views/day/DayView.module.css @@ -21,73 +21,6 @@ flex: 1; } -.timeColumn { - border-right: 1px solid #e0e0e0; -} - -.timeSlot { - height: 60px; /* 1 hour height */ - border-bottom: 1px solid #f0f0f0; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - color: #666; -} - .eventsColumn { position: relative; } - -.eventSlot { - height: 60px; /* Match timeSlot height */ - border-bottom: 1px solid #f0f0f0; -} - -.eventItem { - position: absolute; - left: 5px; - right: 5px; - padding: 4px 5px; - background-color: #3b82f6; - color: white; - border-radius: 4px; - font-size: 12px; - overflow: hidden; - cursor: pointer; - box-sizing: border-box; - width: calc(var(--event-width) - 2px); - display: flex; - flex-direction: column; - line-height: 1.2; -} - -.eventItemSmall { - flex-direction: row; - align-items: center; - gap: 4px; - padding: 2px 5px; -} - -.eventItemTiny { - flex-direction: row; - align-items: center; - gap: 4px; - padding: 1px 4px; - font-size: 10px; - line-height: 1; -} - -.eventTitle { - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.eventTime { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - opacity: 0.9; -} diff --git a/src/views/day/DayView.tsx b/src/views/day/DayView.tsx index c018214..7a159e3 100644 --- a/src/views/day/DayView.tsx +++ b/src/views/day/DayView.tsx @@ -1,22 +1,16 @@ import React, { useMemo } from "react"; -import cx from "classnames"; -import { - dateFn, - formatDate, - DateType, - calculateEventLayout, -} from "../../utils"; +import { formatDate, calculateEventLayout } from "../../utils"; import { DataType } from "../../types"; import styles from "./DayView.module.css"; import { useCalendar } from "../../context/CalendarContext"; +import TimeColumn from "../../common/time_column/TimeColumn"; +import DayColumn from "../../common/day_column/DayColumn"; interface DayViewProps { events: DataType[]; onEventClick?: (event: DataType) => void; } -const HOURS = Array.from({ length: 24 }, (_, i) => i); - function DayView({ events, onEventClick }: DayViewProps) { const { state } = useCalendar(); const { currentDate } = state; @@ -31,54 +25,9 @@ function DayView({ events, onEventClick }: DayViewProps) { {formatDate(currentDate, "dddd, MMMM D, YYYY")}
-
- {HOURS.map((hour) => ( -
- {formatDate(dateFn().hour(hour).minute(0), "HH:mm")} -
- ))} -
+
- {HOURS.map((hour) => ( -
- ))} - {dayEvents.map((item, index) => { - const eventColor = item.event.color || "#3b82f6"; - let tooltipText = `${item.event.value} (${formatDate(item.event.startDate, "HH:mm")}`; - if (item.event.endDate) { - tooltipText += ` - ${formatDate(item.event.endDate, "HH:mm")}`; - } - tooltipText += `)`; - - return ( -
= 20, - [styles.eventItemTiny]: item.height < 20, - })} - style={ - { - top: `${item.top}px`, - height: `${item.height}px`, - left: `${item.left}%`, - zIndex: item.zIndex, - "--event-width": `${item.width}%`, - backgroundColor: eventColor, - position: "absolute", // Ensure it's absolute - } as React.CSSProperties - } - onClick={() => onEventClick?.(item.event)} - title={tooltipText} - > -
{item.event.value}
-
- {formatDate(item.event.startDate, "HH:mm")} -
-
- ); - })} +
diff --git a/src/views/month/MonthView.tsx b/src/views/month/MonthView.tsx index 278bbbf..f55a4d2 100644 --- a/src/views/month/MonthView.tsx +++ b/src/views/month/MonthView.tsx @@ -10,7 +10,7 @@ import { calculateMaxEvents, } from "../../utils"; import styles from "./MonthView.module.css"; -import EventItem from "../../common/event_item/EventItem"; +import MonthEventItem from "../../common/month_event_item/MonthEventItem"; import { useCalendar } from "../../context/CalendarContext"; interface MonthViewProps extends Omit {} @@ -77,7 +77,7 @@ function MonthView({ {calendarGrid.map((week, weekIndex) => ( {week.map((dayInfo, dayIndex) => ( - i); - function WeekView({ events, onEventClick, dayType = "HALF" }: WeekViewProps) { const { state } = useCalendar(); const { currentDate } = state; @@ -53,62 +48,14 @@ function WeekView({ events, onEventClick, dayType = "HALF" }: WeekViewProps) { ))}
-
- {HOURS.map((hour) => ( -
- {formatDate(dateFn().hour(hour).minute(0), "HH:mm")} -
- ))} -
+
{weekDays.map((date, dayIndex) => (
- {HOURS.map((hour) => ( -
- ))} - {weekEvents[dayIndex].map((item, index) => { - const eventColor = item.event.color || "#3b82f6"; - let tooltipText = `${item.event.value} (${formatDate( - item.event.startDate, - "HH:mm", - )}`; - if (item.event.endDate) { - tooltipText += ` - ${formatDate( - item.event.endDate, - "HH:mm", - )}`; - } - tooltipText += `)`; - - return ( -
= 20, - [styles.eventItemTiny]: item.height < 20, - })} - style={ - { - top: `${item.top}px`, - height: `${item.height}px`, - left: `${item.left}%`, - zIndex: item.zIndex, - "--event-width": `${item.width}%`, - backgroundColor: eventColor, - position: "absolute", - } as React.CSSProperties - } - onClick={() => onEventClick?.(item.event)} - title={tooltipText} - > -
{item.event.value}
-
- {formatDate(item.event.startDate, "HH:mm")} -
-
- ); - })} +
))}
From d7aca8bca580d4b320f8c2d5a3eff8c91e2a3f29 Mon Sep 17 00:00:00 2001 From: Jaganath M S Date: Sat, 21 Feb 2026 16:38:16 +0530 Subject: [PATCH 013/160] refactor: centralize magic strings and numbers - Add DATE_FORMATS constant to consolidate date format strings - Add CALENDAR_ACTIONS constant for action type strings - Extend CALENDAR_CONSTANTS with new height constants and default color - Replace hardcoded strings with constants across components - Use ECalendarViewType enum values instead of string literals --- .../day_event_item/DayWeekEventItem.tsx | 11 +++++--- .../month_event_item/MonthEventItem.tsx | 7 ++++-- src/common/time_column/TimeColumn.tsx | 3 ++- src/constants/index.ts | 18 +++++++++++++ src/context/CalendarContext.tsx | 25 ++++++++++--------- src/layout/Header.tsx | 23 ++++++++++------- src/utils/calendarLogic.ts | 5 ++-- src/utils/common.ts | 10 ++++---- 8 files changed, 67 insertions(+), 35 deletions(-) diff --git a/src/common/day_event_item/DayWeekEventItem.tsx b/src/common/day_event_item/DayWeekEventItem.tsx index 8733556..d7a391b 100644 --- a/src/common/day_event_item/DayWeekEventItem.tsx +++ b/src/common/day_event_item/DayWeekEventItem.tsx @@ -3,6 +3,7 @@ import cx from "classnames"; import { formatDate, generateTooltipText } from "../../utils"; import { DataType } from "../../types"; import { DayEventLayout } from "../../utils/eventLayout"; +import { CALENDAR_CONSTANTS, DATE_FORMATS } from "../../constants"; import styles from "./DayWeekEventItem.module.css"; interface DayWeekEventItemProps { @@ -14,11 +15,13 @@ export function DayWeekEventItem({ item, onEventClick, }: DayWeekEventItemProps) { - const eventColor = item.event.color || "#3b82f6"; + const eventColor = item.event.color || CALENDAR_CONSTANTS.DEFAULT_EVENT_COLOR; const tooltipText = generateTooltipText(item.event, "day"); - const isSmall = item.height < 40 && item.height >= 20; - const isTiny = item.height < 20; + const isSmall = + item.height < CALENDAR_CONSTANTS.SMALL_EVENT_HEIGHT && + item.height >= CALENDAR_CONSTANTS.TINY_EVENT_HEIGHT; + const isTiny = item.height < CALENDAR_CONSTANTS.TINY_EVENT_HEIGHT; return (
{item.event.value}
- {formatDate(item.event.startDate, "HH:mm")} + {formatDate(item.event.startDate, DATE_FORMATS.TIME)}
); diff --git a/src/common/month_event_item/MonthEventItem.tsx b/src/common/month_event_item/MonthEventItem.tsx index e329910..42be784 100644 --- a/src/common/month_event_item/MonthEventItem.tsx +++ b/src/common/month_event_item/MonthEventItem.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef } from "react"; import cx from "classnames"; -import { DataTypeList, DateDataType } from "../../types"; +import { DataTypeList, DateDataType, ECalendarViewType } from "../../types"; import { formatDate, getDiffDays, generateTooltipText } from "../../utils"; import styles from "./MonthEventItem.module.css"; import Popover from "../popover/Popover"; @@ -86,7 +86,10 @@ function MonthEventItem({ diffDates = getDiffDays(item.endDateWeek, item.startDateWeek) + 1; } - const tooltipText = generateTooltipText(item, "month"); + const tooltipText = generateTooltipText( + item, + ECalendarViewType.month, + ); const width = `${cellWidth * diffDates - CALENDAR_CONSTANTS.EVENT_ITEM_PADDING}px`; return ( diff --git a/src/common/time_column/TimeColumn.tsx b/src/common/time_column/TimeColumn.tsx index 4b6cbf4..fc4b409 100644 --- a/src/common/time_column/TimeColumn.tsx +++ b/src/common/time_column/TimeColumn.tsx @@ -1,5 +1,6 @@ import React from "react"; import { dateFn, formatDate } from "../../utils"; +import { DATE_FORMATS } from "../../constants"; import styles from "./TimeColumn.module.css"; const HOURS = Array.from({ length: 24 }, (_, i) => i); @@ -9,7 +10,7 @@ function TimeColumn() {
{HOURS.map((hour) => (
- {formatDate(dateFn().hour(hour).minute(0), "HH:mm")} + {formatDate(dateFn().hour(hour).minute(0), DATE_FORMATS.TIME)}
))}
diff --git a/src/constants/index.ts b/src/constants/index.ts index 40c4e07..bf13e1f 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -108,4 +108,22 @@ export const CALENDAR_CONSTANTS = { MIN_ROWS: 4, DAYS_IN_WEEK: 7, EVENT_ITEM_PADDING: 16, // used in width calculation + SMALL_EVENT_HEIGHT: 40, + TINY_EVENT_HEIGHT: 20, + DEFAULT_EVENT_COLOR: "#3b82f6", }; + +export const DATE_FORMATS = { + DATE: "YYYY-MM-DD", + TIME: "HH:mm", + MONTH_YEAR: "MMMM YYYY", + DAY_INDEX: "d", +}; + +export const CALENDAR_ACTIONS = { + SET_DATE: "SET_DATE", + SET_VIEW: "SET_VIEW", + NEXT: "NEXT", + PREV: "PREV", + TODAY: "TODAY", +} as const; diff --git a/src/context/CalendarContext.tsx b/src/context/CalendarContext.tsx index 86a48d0..ca04728 100644 --- a/src/context/CalendarContext.tsx +++ b/src/context/CalendarContext.tsx @@ -7,6 +7,7 @@ import React, { } from "react"; import { dateFn, DateType } from "../utils"; import { DataType, ECalendarViewType } from "../types"; +import { CALENDAR_ACTIONS } from "../constants"; interface CalendarState { currentDate: DateType; @@ -15,16 +16,16 @@ interface CalendarState { } type CalendarAction = - | { type: "SET_DATE"; payload: DateType } - | { type: "SET_VIEW"; payload: ECalendarViewType } - | { type: "NEXT" } - | { type: "PREV" } - | { type: "TODAY" }; + | { type: typeof CALENDAR_ACTIONS.SET_DATE; payload: DateType } + | { type: typeof CALENDAR_ACTIONS.SET_VIEW; payload: ECalendarViewType } + | { type: typeof CALENDAR_ACTIONS.NEXT } + | { type: typeof CALENDAR_ACTIONS.PREV } + | { type: typeof CALENDAR_ACTIONS.TODAY }; const initialState: CalendarState = { currentDate: dateFn(), selectedDate: dateFn(), - view: "month", + view: ECalendarViewType.month, }; const CalendarContext = createContext< @@ -40,22 +41,22 @@ function calendarReducer( action: CalendarAction, ): CalendarState { switch (action.type) { - case "SET_DATE": + case CALENDAR_ACTIONS.SET_DATE: return { ...state, currentDate: action.payload, selectedDate: action.payload, }; - case "SET_VIEW": + case CALENDAR_ACTIONS.SET_VIEW: return { ...state, view: action.payload }; - case "NEXT": + case CALENDAR_ACTIONS.NEXT: return { ...state, currentDate: state.currentDate.add(1, state.view) }; - case "PREV": + case CALENDAR_ACTIONS.PREV: return { ...state, currentDate: state.currentDate.subtract(1, state.view), }; - case "TODAY": + case CALENDAR_ACTIONS.TODAY: return { ...state, currentDate: dateFn(), @@ -76,7 +77,7 @@ interface CalendarProviderProps { export function CalendarProvider({ children, initialDate, - initialView = "month", + initialView = ECalendarViewType.month, }: CalendarProviderProps) { const [state, dispatch] = useReducer(calendarReducer, { ...initialState, diff --git a/src/layout/Header.tsx b/src/layout/Header.tsx index acded4b..6db9ea1 100644 --- a/src/layout/Header.tsx +++ b/src/layout/Header.tsx @@ -7,7 +7,12 @@ import { EYearOption, MonthListType, } from "../types"; -import { CALENDER_STRINGS, MONTH_LIST } from "../constants"; +import { + CALENDER_STRINGS, + MONTH_LIST, + DATE_FORMATS, + CALENDAR_ACTIONS, +} from "../constants"; import { dateFn, getYearList, @@ -44,7 +49,7 @@ function Header({ const onMonthArrowClick = (option: EMonthOption) => { const isAdd = option === EMonthOption.add; - dispatch({ type: isAdd ? "NEXT" : "PREV" }); + dispatch({ type: isAdd ? CALENDAR_ACTIONS.NEXT : CALENDAR_ACTIONS.PREV }); const predictiveDate = isAdd ? currentDate.add(1, view) @@ -66,13 +71,13 @@ function Header({ newDate = setYear(currentDate, value); } - dispatch({ type: "SET_DATE", payload: newDate }); + dispatch({ type: CALENDAR_ACTIONS.SET_DATE, payload: newDate }); onMonthChange?.(convertToDate(newDate)); }; const onViewDropdownClick = (e: ChangeEvent) => { const newView = e.target.value as ECalendarViewType; - dispatch({ type: "SET_VIEW", payload: newView }); + dispatch({ type: CALENDAR_ACTIONS.SET_VIEW, payload: newView }); onViewChange?.(newView); }; @@ -82,7 +87,7 @@ function Header({

- {formatDate(currentDate, "MMMM YYYY")} + {formatDate(currentDate, DATE_FORMATS.MONTH_YEAR)}

@@ -113,9 +118,9 @@ function Header({ value={view} onChange={onViewDropdownClick} > - - - + + + onDropdownClick(e, EYearOption.month)} > @@ -223,8 +223,8 @@ function Header({ {VIEW_OPTIONS.map((option) => ( @@ -225,6 +226,7 @@ function Header({ id={CALENDAR_STRINGS.MONTH} name={CALENDAR_STRINGS.MONTH} value={getMonth(selectedDate)} + data-testid={`${testId}-header-month-select`} onChange={(e) => onDropdownClick(e, EYearOption.month)} > {MONTH_LIST.map((month: MonthListType) => ( @@ -238,6 +240,7 @@ function Header({ id={CALENDAR_STRINGS.YEAR} name={CALENDAR_STRINGS.YEAR} value={getYear(selectedDate)} + data-testid={`${testId}-header-year-select`} onChange={(e) => onDropdownClick(e, EYearOption.year)} > {getYearList( diff --git a/src/components/ui/popover/Popover.tsx b/src/components/ui/popover/Popover.tsx index 32c1f9b..61dc94d 100644 --- a/src/components/ui/popover/Popover.tsx +++ b/src/components/ui/popover/Popover.tsx @@ -152,6 +152,7 @@ function Popover({ [styles.endAfter]: isEndAfter, })} id={item.id} + data-testid={`${testId}-${item.id}-popover-item`} style={{ backgroundColor: LAYOUT_CONSTANTS.DEFAULT_EVENT_COLOR, ...item.style, diff --git a/src/components/views/schedule_view/ScheduleView.tsx b/src/components/views/schedule_view/ScheduleView.tsx index b5b9674..29361c3 100644 --- a/src/components/views/schedule_view/ScheduleView.tsx +++ b/src/components/views/schedule_view/ScheduleView.tsx @@ -88,6 +88,7 @@ export default function ScheduleView({ styles.eventItemContainer, classNames?.event, )} + data-testid={`${testId}-${event.id}-schedule-event`} onClick={() => onEventClick?.(event)} title={generateTooltipText( event, From 1be93db474ff33f9a3a13c9532cf5fedf6d25e72 Mon Sep 17 00:00:00 2001 From: Jaganath M S Date: Sun, 5 Apr 2026 17:16:26 +0530 Subject: [PATCH 088/160] feat(calendar)!: add internationalization support - Add `locale` and `localeMessages` props to Calendar component to support localization - Update `formatDate` utility to accept locale parameter for dayjs formatting - Replace hardcoded day and month name arrays with dynamic generation using dayjs locale - Pass locale through all view components (DayView, WeekView, MonthView, etc.) - Update header to use localized strings for "Today", view labels, and month names - Ensure tooltips and event times respect the specified locale - Provide default English messages while allowing full customization via localeMessages BREAKING CHANGE: add locale and localeMessages props for internationalization --- playground/src/App.tsx | 9 +++ src/Calendar.tsx | 6 ++ .../core/all_day_banner/AllDayBanner.tsx | 9 ++- .../core/time_column/TimeColumn.tsx | 5 +- src/components/layout/Header.tsx | 37 ++++++---- .../views/custom_days_view/CustomDaysView.tsx | 10 ++- src/components/views/day_view/DayView.tsx | 10 ++- src/components/views/month_view/MonthView.tsx | 8 +- .../views/schedule_view/ScheduleView.tsx | 12 ++- src/components/views/week_view/WeekView.tsx | 10 ++- src/constants/calendar.ts | 74 +++++++------------ src/hooks/useScheduleView.ts | 14 ++-- src/types/calendar.ts | 16 ++++ src/utils/date.ts | 10 ++- src/utils/formatting.ts | 7 +- 15 files changed, 148 insertions(+), 89 deletions(-) diff --git a/playground/src/App.tsx b/playground/src/App.tsx index 34aa87f..3c4fd32 100644 --- a/playground/src/App.tsx +++ b/playground/src/App.tsx @@ -205,6 +205,15 @@ function App() { is12Hour showCurrentTime autoScrollToCurrentTime + locale="es-MX" + localeMessages={{ + day: "நாள்", + week: "வாரம்", + month: "மாதம்", + schedule: "நிகழ்ச்சி", + days: "நாட்கள்", + today: "இன்று", + }} // width={400} // height={400} /> diff --git a/src/Calendar.tsx b/src/Calendar.tsx index 0d3289d..fc26878 100644 --- a/src/Calendar.tsx +++ b/src/Calendar.tsx @@ -52,6 +52,8 @@ function CalendarContent({ isEventOrderingEnabled, sortedMonthView, testId, + locale, + localeMessages, ...restProps }: CalendarContentProps) { const { @@ -99,6 +101,8 @@ function CalendarContent({ eventsAreSorted, isEventOrderingEnabled, sortedMonthView, + locale, + localeMessages, }; switch (view) { case ECalendarViewType.day: @@ -163,6 +167,8 @@ function CalendarContent({ futureYearLength={futureYearLength} customDays={customDays} resetDateOnViewChange={restProps.resetDateOnViewChange} + locale={locale} + localeMessages={localeMessages} /> )} {getViewComponent(view)} diff --git a/src/components/core/all_day_banner/AllDayBanner.tsx b/src/components/core/all_day_banner/AllDayBanner.tsx index c0602b2..b38fcfd 100644 --- a/src/components/core/all_day_banner/AllDayBanner.tsx +++ b/src/components/core/all_day_banner/AllDayBanner.tsx @@ -15,7 +15,12 @@ import { useCalendar } from "../../../context/CalendarContext"; interface AllDayBannerProps extends Pick< CalendarContentProps, - "maxEvents" | "onEventClick" | "classNames" | "is12Hour" | "renderEvent" + | "maxEvents" + | "onEventClick" + | "classNames" + | "is12Hour" + | "renderEvent" + | "locale" > { days: DateType[]; events: CalendarEvent[]; @@ -29,6 +34,7 @@ export default function AllDayBanner({ classNames, is12Hour, renderEvent, + locale, }: AllDayBannerProps) { const { testId } = useCalendar(); const [isExpanded, setIsExpanded] = useState(false); @@ -146,6 +152,7 @@ export default function AllDayBanner({ event, ECalendarViewType.week, is12Hour, + locale, )} > {renderEvent ? ( diff --git a/src/components/core/time_column/TimeColumn.tsx b/src/components/core/time_column/TimeColumn.tsx index 3b9c2b1..075dbf8 100644 --- a/src/components/core/time_column/TimeColumn.tsx +++ b/src/components/core/time_column/TimeColumn.tsx @@ -8,7 +8,7 @@ import { useCalendar } from "../../../context/CalendarContext"; interface TimeColumnProps extends Pick< CalendarContentProps, - "is12Hour" | "classNames" | "minHour" | "maxHour" + "is12Hour" | "classNames" | "minHour" | "maxHour" | "locale" > {} function TimeColumn({ @@ -16,6 +16,7 @@ function TimeColumn({ classNames, minHour, maxHour, + locale, }: TimeColumnProps) { const { testId } = useCalendar(); const hours = Array.from( @@ -32,7 +33,7 @@ function TimeColumn({ const timeFormat = is12Hour ? DATE_FORMATS.HOUR_12H : DATE_FORMATS.TIME; return (
- {formatDate(dateFn().hour(hour).minute(0), timeFormat)} + {formatDate(dateFn().hour(hour).minute(0), timeFormat, locale)}
); })} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index bf926b4..5da753e 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -7,7 +7,7 @@ import { } from "../../types"; import { CALENDAR_STRINGS, - MONTH_LIST, + getMonthList, DATE_FORMATS, CALENDAR_ACTIONS, VIEW_OPTIONS, @@ -47,6 +47,8 @@ interface HeaderProps extends Pick< | "events" | "customDays" | "resetDateOnViewChange" + | "locale" + | "localeMessages" > { headerClassName?: string; } @@ -60,6 +62,8 @@ function Header({ customDays, events, resetDateOnViewChange, + locale, + localeMessages, }: HeaderProps) { const { state, dispatch, testId } = useCalendar(); const { selectedDate, view } = state; @@ -115,16 +119,16 @@ function Header({ const getHeaderTitle = () => { if (view === ECalendarViewType.day) { - return formatDate(selectedDate, DATE_FORMATS.MONTH_DAY_YEAR); + return formatDate(selectedDate, DATE_FORMATS.MONTH_DAY_YEAR, locale); } if (view === ECalendarViewType.week) { const startOfWeek = selectedDate.startOf("week"); const endOfWeek = selectedDate.endOf("week"); if (startOfWeek.month() !== endOfWeek.month()) { if (startOfWeek.year() !== endOfWeek.year()) { - return `${formatDate(startOfWeek, DATE_FORMATS.SHORT_MONTH_YEAR)} - ${formatDate(endOfWeek, DATE_FORMATS.SHORT_MONTH_YEAR)}`; + return `${formatDate(startOfWeek, DATE_FORMATS.SHORT_MONTH_YEAR, locale)} - ${formatDate(endOfWeek, DATE_FORMATS.SHORT_MONTH_YEAR, locale)}`; } - return `${formatDate(startOfWeek, DATE_FORMATS.SHORT_MONTH)} - ${formatDate(endOfWeek, DATE_FORMATS.SHORT_MONTH_YEAR)}`; + return `${formatDate(startOfWeek, DATE_FORMATS.SHORT_MONTH, locale)} - ${formatDate(endOfWeek, DATE_FORMATS.SHORT_MONTH_YEAR, locale)}`; } } if (view === ECalendarViewType.customDays) { @@ -132,14 +136,14 @@ function Header({ const endDate = selectedDate.add(days - 1, "day"); if (selectedDate.month() !== endDate.month()) { if (selectedDate.year() !== endDate.year()) { - return `${formatDate(selectedDate, DATE_FORMATS.SHORT_MONTH_YEAR)} - ${formatDate(endDate, DATE_FORMATS.SHORT_MONTH_YEAR)}`; + return `${formatDate(selectedDate, DATE_FORMATS.SHORT_MONTH_YEAR, locale)} - ${formatDate(endDate, DATE_FORMATS.SHORT_MONTH_YEAR, locale)}`; } - return `${formatDate(selectedDate, DATE_FORMATS.SHORT_MONTH)} - ${formatDate(endDate, DATE_FORMATS.SHORT_MONTH_YEAR)}`; + return `${formatDate(selectedDate, DATE_FORMATS.SHORT_MONTH, locale)} - ${formatDate(endDate, DATE_FORMATS.SHORT_MONTH_YEAR, locale)}`; } if (days === 1) { - return formatDate(selectedDate, DATE_FORMATS.MONTH_DAY_YEAR); + return formatDate(selectedDate, DATE_FORMATS.MONTH_DAY_YEAR, locale); } - return `${formatDate(selectedDate, DATE_FORMATS.DAY_DATE_SHORT_MONTH)} - ${formatDate(endDate, DATE_FORMATS.DAY_DATE_SHORT_MONTH)}, ${formatDate(selectedDate, "YYYY")}`; + return `${formatDate(selectedDate, DATE_FORMATS.DAY_DATE_SHORT_MONTH, locale)} - ${formatDate(endDate, DATE_FORMATS.DAY_DATE_SHORT_MONTH, locale)}, ${formatDate(selectedDate, "YYYY")}`; } if (view === ECalendarViewType.schedule) { if (events && events.length > 0) { @@ -158,14 +162,14 @@ function Header({ minDate.year() !== maxDate.year() ) { if (minDate.year() !== maxDate.year()) { - return `${formatDate(minDate, DATE_FORMATS.SHORT_MONTH_YEAR)} - ${formatDate(maxDate, DATE_FORMATS.SHORT_MONTH_YEAR)}`; + return `${formatDate(minDate, DATE_FORMATS.SHORT_MONTH_YEAR, locale)} - ${formatDate(maxDate, DATE_FORMATS.SHORT_MONTH_YEAR, locale)}`; } - return `${formatDate(minDate, DATE_FORMATS.SHORT_MONTH)} - ${formatDate(maxDate, DATE_FORMATS.SHORT_MONTH_YEAR)}`; + return `${formatDate(minDate, DATE_FORMATS.SHORT_MONTH, locale)} - ${formatDate(maxDate, DATE_FORMATS.SHORT_MONTH_YEAR, locale)}`; } - return formatDate(minDate, DATE_FORMATS.SHORT_MONTH_YEAR); + return formatDate(minDate, DATE_FORMATS.SHORT_MONTH_YEAR, locale); } } - return formatDate(selectedDate, DATE_FORMATS.MONTH_YEAR); + return formatDate(selectedDate, DATE_FORMATS.MONTH_YEAR, locale); }; return ( @@ -182,7 +186,7 @@ function Header({ onNavigate?.(convertToDate(dateFn())); }} > - Today + {localeMessages?.today || "Today"}
); @@ -159,6 +161,7 @@ function CustomView({ classNames={classNames} is12Hour={is12Hour} renderEvent={renderEvent} + locale={locale} /> )} @@ -168,6 +171,7 @@ function CustomView({ classNames={classNames} minHour={minHour} maxHour={maxHour} + locale={locale} />
{viewDays.map((date, dayIndex) => { diff --git a/src/components/views/day_view/DayView.tsx b/src/components/views/day_view/DayView.tsx index 1944404..a2100fd 100644 --- a/src/components/views/day_view/DayView.tsx +++ b/src/components/views/day_view/DayView.tsx @@ -5,7 +5,7 @@ import useDayEventLayout, { DayEventLayout, } from "../../../hooks/useDayEventLayout"; import { CalendarContentProps } from "../../../types"; -import { DAY_LIST_NAME, DATE_FORMATS } from "../../../constants"; +import { getDayListNames, DATE_FORMATS } from "../../../constants"; import styles from "./DayView.module.css"; import { useCalendar } from "../../../context/CalendarContext"; import TimeColumn from "../../core/time_column/TimeColumn"; @@ -34,6 +34,7 @@ interface DayViewProps extends Pick< | "enrichedEventsByDate" | "eventsAreSorted" | "isEventOrderingEnabled" + | "locale" > {} function DayView({ @@ -57,6 +58,7 @@ function DayView({ enrichedEventsByDate, eventsAreSorted, isEventOrderingEnabled, + locale, }: DayViewProps) { const containerRef = useRef(null); const { state, testId } = useCalendar(); @@ -119,7 +121,7 @@ function DayView({ ) : (
- {DAY_LIST_NAME[dayType][selectedDate.day()]} + {getDayListNames(dayType, locale)[selectedDate.day()]}
- {formatDate(selectedDate, DATE_FORMATS.DAY_NUMBER)} + {formatDate(selectedDate, DATE_FORMATS.DAY_NUMBER, locale)}
)} @@ -141,6 +143,7 @@ function DayView({ classNames={classNames} is12Hour={is12Hour} renderEvent={renderEvent} + locale={locale} /> )}
@@ -150,6 +153,7 @@ function DayView({ classNames={classNames} minHour={minHour} maxHour={maxHour} + locale={locale} />
{} function MonthView({ @@ -60,6 +61,7 @@ function MonthView({ eventsAreSorted, isEventOrderingEnabled, sortedMonthView, + locale, ...restProps }: MonthViewProps) { const { state, dispatch, testId } = useCalendar(); @@ -101,10 +103,10 @@ function MonthView({ ); const headerDays = useMemo(() => { - const list = DAY_LIST_NAME[dayType]; + const list = getDayListNames(dayType, locale); const length = ((weekEndsOn - weekStartsOn + 7) % 7) + 1; return Array.from({ length }, (_, i) => list[(weekStartsOn + i) % 7]); - }, [dayType, weekStartsOn, weekEndsOn]); + }, [dayType, weekStartsOn, weekEndsOn, locale]); return (
diff --git a/src/components/views/schedule_view/ScheduleView.tsx b/src/components/views/schedule_view/ScheduleView.tsx index 29361c3..9d6bf72 100644 --- a/src/components/views/schedule_view/ScheduleView.tsx +++ b/src/components/views/schedule_view/ScheduleView.tsx @@ -23,6 +23,7 @@ interface ScheduleViewProps extends Pick< | "autoScrollToCurrentTime" | "renderEvent" | "renderScheduleSeparator" + | "locale" > {} export default function ScheduleView({ @@ -34,6 +35,7 @@ export default function ScheduleView({ autoScrollToCurrentTime, renderEvent, renderScheduleSeparator, + locale, }: ScheduleViewProps) { const { testId } = useCalendar(); const { todayRef, groupedEvents, renderEventTime, renderEventTitle } = @@ -41,6 +43,7 @@ export default function ScheduleView({ events, autoScrollToCurrentTime, is12Hour, + locale, }); return ( @@ -94,6 +97,7 @@ export default function ScheduleView({ event, ECalendarViewType.schedule, is12Hour, + locale, )} > {/* Column 1: Date Info (only shown on the first event of the day) */} @@ -110,7 +114,11 @@ export default function ScheduleView({ )} style={todayStyle} > - {formatDate(dateObj, DATE_FORMATS.DAY_NUMBER)} + {formatDate( + dateObj, + DATE_FORMATS.DAY_NUMBER, + locale, + )}
diff --git a/src/components/views/week_view/WeekView.tsx b/src/components/views/week_view/WeekView.tsx index ebd8e5a..c95e834 100644 --- a/src/components/views/week_view/WeekView.tsx +++ b/src/components/views/week_view/WeekView.tsx @@ -5,7 +5,7 @@ import useDayEventLayout, { DayEventLayout, } from "../../../hooks/useDayEventLayout"; import { CalendarContentProps } from "../../../types"; -import { DAY_LIST_NAME, DATE_FORMATS } from "../../../constants"; +import { getDayListNames, DATE_FORMATS } from "../../../constants"; import styles from "./WeekView.module.css"; import { useCalendar } from "../../../context/CalendarContext"; import TimeColumn from "../../core/time_column/TimeColumn"; @@ -36,6 +36,7 @@ interface WeekViewProps extends Pick< | "enrichedEventsByDate" | "eventsAreSorted" | "isEventOrderingEnabled" + | "locale" > {} function WeekView({ @@ -61,6 +62,7 @@ function WeekView({ enrichedEventsByDate, eventsAreSorted, isEventOrderingEnabled, + locale, }: WeekViewProps) { const containerRef = useRef(null); const { state, testId } = useCalendar(); @@ -146,7 +148,7 @@ function WeekView({ className={cx(styles.dayHeader, classNames?.dayHeader)} >
- {DAY_LIST_NAME[dayType][date.day()]} + {getDayListNames(dayType, locale)[date.day()]}
- {formatDate(date, DATE_FORMATS.DAY_NUMBER)} + {formatDate(date, DATE_FORMATS.DAY_NUMBER, locale)}
); @@ -169,6 +171,7 @@ function WeekView({ classNames={classNames} is12Hour={is12Hour} renderEvent={renderEvent} + locale={locale} /> )}
@@ -178,6 +181,7 @@ function WeekView({ classNames={classNames} minHour={minHour} maxHour={maxHour} + locale={locale} />
{weekDays.map((date, dayIndex) => { diff --git a/src/constants/calendar.ts b/src/constants/calendar.ts index 2c076e2..36137a8 100644 --- a/src/constants/calendar.ts +++ b/src/constants/calendar.ts @@ -1,52 +1,25 @@ import { ECalendarViewType, EDayType, MonthListType } from "../types"; +import { dateFn } from "../utils/date"; -export const DAY_LIST = { - SUNDAY: { FULL: "Sunday", HALF: "Sun" }, - MONDAY: { FULL: "Monday", HALF: "Mon" }, - TUESDAY: { FULL: "Tuesday", HALF: "Tue" }, - WEDNESDAY: { FULL: "Wednesday", HALF: "Wed" }, - THURSDAY: { FULL: "Thursday", HALF: "Thu" }, - FRIDAY: { FULL: "Friday", HALF: "Fri" }, - SATURDAY: { FULL: "Saturday", HALF: "Sat" }, -}; - -export const DAY_LIST_NAME = { - [EDayType.full]: [ - DAY_LIST.SUNDAY.FULL, - DAY_LIST.MONDAY.FULL, - DAY_LIST.TUESDAY.FULL, - DAY_LIST.WEDNESDAY.FULL, - DAY_LIST.THURSDAY.FULL, - DAY_LIST.FRIDAY.FULL, - DAY_LIST.SATURDAY.FULL, - ], - [EDayType.half]: [ - DAY_LIST.SUNDAY.HALF, - DAY_LIST.MONDAY.HALF, - DAY_LIST.TUESDAY.HALF, - DAY_LIST.WEDNESDAY.HALF, - DAY_LIST.THURSDAY.HALF, - DAY_LIST.FRIDAY.HALF, - DAY_LIST.SATURDAY.HALF, - ], -}; - -const MONTHS = { - JAN: { label: "January", value: 0 }, - FEB: { label: "February", value: 1 }, - MAR: { label: "March", value: 2 }, - APR: { label: "April", value: 3 }, - MAY: { label: "May", value: 4 }, - JUN: { label: "June", value: 5 }, - JUL: { label: "July", value: 6 }, - AUG: { label: "August", value: 7 }, - SEP: { label: "September", value: 8 }, - OCT: { label: "October", value: 9 }, - NOV: { label: "November", value: 10 }, - DEC: { label: "December", value: 11 }, -}; +export function getDayListNames(dayType: EDayType, locale?: string): string[] { + const format = dayType === EDayType.full ? "dddd" : "ddd"; + return Array.from({ length: 7 }, (_, i) => + dateFn() + .day(i) + .locale(locale || "en") + .format(format), + ); +} -export const MONTH_LIST: MonthListType[] = Object.values(MONTHS); +export function getMonthList(locale?: string): MonthListType[] { + return Array.from({ length: 12 }, (_, i) => ({ + label: dateFn() + .month(i) + .locale(locale || "en") + .format("MMMM"), + value: i, + })); +} export const CALENDAR_STRINGS = { MONTH: "monthDropdown", @@ -63,6 +36,15 @@ export const VIEW_OPTIONS = [ export const defaultCalendarProps = { events: [], view: ECalendarViewType.month, + locale: "en", + localeMessages: { + today: "Today", + day: "Day", + week: "Week", + month: "Month", + schedule: "Schedule", + days: "Days", + }, is12Hour: false, selectable: false, dayType: EDayType.half, diff --git a/src/hooks/useScheduleView.ts b/src/hooks/useScheduleView.ts index 8397ee3..70ada26 100644 --- a/src/hooks/useScheduleView.ts +++ b/src/hooks/useScheduleView.ts @@ -27,6 +27,7 @@ interface UseScheduleViewProps { events: CalendarEvent[]; autoScrollToCurrentTime?: boolean; is12Hour?: boolean; + locale?: string; } /** @@ -39,6 +40,7 @@ export default function useScheduleView({ events, autoScrollToCurrentTime, is12Hour, + locale, }: UseScheduleViewProps) { const todayRef = useRef(null); @@ -64,7 +66,7 @@ export default function useScheduleView({ : current; while (current.isBefore(end) || current.isSame(end)) { - const dateKey = formatDate(current, DATE_FORMATS.DATE); + const dateKey = formatDate(current, DATE_FORMATS.DATE, locale); if (!groups[dateKey]) { groups[dateKey] = []; } @@ -74,7 +76,7 @@ export default function useScheduleView({ }); return groups; - }, [events]); + }, [events, locale]); useEffect(() => { if (autoScrollToCurrentTime && todayRef.current) { @@ -115,23 +117,23 @@ export default function useScheduleView({ if (currentDay.isSame(startDay)) { return isMidnight(event.startDate) ? "All day" - : `${formatTime(formatDate(event.startDate, timeFormat))}`; + : `${formatTime(formatDate(event.startDate, timeFormat, locale))}`; } else if (currentDay.isSame(endDay)) { return isEndOfDay(event.endDate!) ? "All day" - : `Until ${formatTime(formatDate(event.endDate!, timeFormat))}`; + : `Until ${formatTime(formatDate(event.endDate!, timeFormat, locale))}`; } else { return "All day"; } } // Normal single day time range - const startStr = formatDate(event.startDate, timeFormat); + const startStr = formatDate(event.startDate, timeFormat, locale); if (event.endDate) { if (isMidnight(event.startDate) && isEndOfDay(event.endDate)) { return "All day"; } - const endStr = formatDate(event.endDate, timeFormat); + const endStr = formatDate(event.endDate, timeFormat, locale); return `${formatTime(startStr)} – ${formatTime(endStr)}`; } return formatTime(startStr); diff --git a/src/types/calendar.ts b/src/types/calendar.ts index 1aac973..1840f5b 100644 --- a/src/types/calendar.ts +++ b/src/types/calendar.ts @@ -91,6 +91,20 @@ export interface CalendarProps { eventsAreSorted?: boolean; isEventOrderingEnabled?: boolean; sortedMonthView?: boolean | ((a: CalendarEvent, b: CalendarEvent) => number); + + // --- Localization --- + /** the dayjs locale code (e.g., 'en', 'fr', 'es-mx'). Requires importing the locale in dayjs. */ + locale?: string; + + /** Translations for built-in calendar text elements */ + localeMessages?: { + today?: string; + day?: string; + week?: string; + month?: string; + schedule?: string; + days?: string; // used in custom days dropdown like '3 Days' + }; } export interface CalendarContentProps extends RequiredSome< @@ -120,6 +134,8 @@ export interface CalendarContentProps extends RequiredSome< | "eventsAreSorted" | "isEventOrderingEnabled" | "sortedMonthView" + | "locale" + | "localeMessages" > {} export interface MonthListType { diff --git a/src/utils/date.ts b/src/utils/date.ts index da4f89c..8455e12 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -86,8 +86,14 @@ export function isSameDate(date1: DateType, date2: DateType): boolean { return dateFn(date1).isSame(dateFn(date2), "day"); } -export function formatDate(date: DateInputType, format: string): string { - return dateFn(date).format(format); +export function formatDate( + date: DateInputType, + format: string, + locale?: string, +): string { + return dateFn(date) + .locale(locale || "en") + .format(format); } export function convertToDate(dayjsDate: DateType): Date { diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index 2c77ddd..abef340 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -1,4 +1,4 @@ -/** +/** * @file formatting.ts * @description Utilities to format data for user interface presentation. * @@ -25,6 +25,7 @@ export function generateTooltipText( event: CalendarEvent, viewType: ECalendarViewType, is12Hour?: boolean, + locale?: string, ): string { const timeFormat = is12Hour ? DATE_FORMATS.TIME_12H : DATE_FORMATS.TIME; const isMulti = @@ -40,9 +41,9 @@ export function generateTooltipText( formatStr = `${DATE_FORMATS.DATE} ${timeFormat}`; } - let tooltipText = `${event.title} (${formatDate(event.startDate, formatStr)}`; + let tooltipText = `${event.title} (${formatDate(event.startDate, formatStr, locale)}`; if (event.endDate) { - tooltipText += ` - ${formatDate(event.endDate, formatStr)}`; + tooltipText += ` - ${formatDate(event.endDate, formatStr, locale)}`; } tooltipText += `)`; From b0410025d07a384e9cfd3cb6dbe8b3170121ba9f Mon Sep 17 00:00:00 2001 From: Jaganath M S Date: Sun, 5 Apr 2026 17:17:20 +0530 Subject: [PATCH 089/160] chore: update ESLint config to use new flat config format Migrate from deprecated defineConfig API to tseslint.config with explicit plugin and rule configuration. This ensures compatibility with the latest ESLint flat config system and provides more explicit control over plugin configurations. --- playground/eslint.config.js | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/playground/eslint.config.js b/playground/eslint.config.js index b19330b..cb065f0 100644 --- a/playground/eslint.config.js +++ b/playground/eslint.config.js @@ -3,21 +3,27 @@ import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' -export default defineConfig([ - globalIgnores(['dist']), +export default tseslint.config( + { ignores: ['dist'] }, + js.configs.recommended, + ...tseslint.configs.recommended, { files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs['recommended-latest'], - reactRefresh.configs.vite, - ], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, }, -]) +) From 7e9d3e3f7c5d8dc7269bf86809fb99687cf38fc2 Mon Sep 17 00:00:00 2001 From: Jaganath M S Date: Mon, 6 Apr 2026 21:25:30 +0530 Subject: [PATCH 090/160] refactor!: migrate from dayjs to luxon for date handling - Replace dayjs with luxon across all components, hooks, and utilities - Update date method calls to use luxon's API (toJSDate(), toFormat(), plus/minus, hasSame, etc.) - Adjust date format strings to match luxon's syntax (yyyy-MM-dd, HH:mm, etc.) - Update test files to use luxon-compatible assertions and mock data - Add @types/luxon to devDependencies and remove dayjs from dependencies - Fix date comparison logic to use luxon's comparison operators and methods - Update week day and month name generation to use luxon's Info utilities BREAKING CHANGE: The calendar now uses Luxon instead of Day.js for all date manipulation. --- package-lock.json | 25 ++-- package.json | 3 +- src/Calendar.test.tsx | 7 +- src/Calendar.tsx | 2 +- .../core/all_day_banner/AllDayBanner.test.tsx | 9 ++ .../current_time_line/CurrentTimeLine.tsx | 6 +- .../core/day_column/DayColumn.test.tsx | 9 ++ .../day_event_item/DayWeekEventItem.test.tsx | 9 ++ .../month_event_item/MonthEventItem.test.tsx | 9 ++ .../core/month_event_item/MonthEventItem.tsx | 2 +- .../core/time_column/TimeColumn.test.tsx | 17 ++- .../core/time_column/TimeColumn.tsx | 2 +- src/components/layout/Header.test.tsx | 2 + src/components/layout/Header.tsx | 40 +++--- .../custom_days_view/CustomDaysView.test.tsx | 9 ++ .../views/custom_days_view/CustomDaysView.tsx | 18 +-- .../views/day_view/DayView.test.tsx | 9 ++ src/components/views/day_view/DayView.tsx | 12 +- .../views/month_view/MonthView.test.tsx | 9 ++ src/components/views/month_view/MonthView.tsx | 4 +- .../views/schedule_view/ScheduleView.test.tsx | 9 ++ .../views/schedule_view/ScheduleView.tsx | 4 +- .../views/week_view/WeekView.test.tsx | 9 ++ src/components/views/week_view/WeekView.tsx | 22 ++-- src/constants/calendar.ts | 42 +++---- src/context/CalendarContext.test.tsx | 4 +- src/context/CalendarContext.tsx | 25 ++-- src/hooks/useAllDayBanner.ts | 16 +-- src/hooks/useDayEventLayout.ts | 14 +-- src/hooks/useEvents.ts | 2 +- src/hooks/useMonthGrid.ts | 2 +- src/hooks/useScheduleView.ts | 14 +-- src/stories/CustomDayView.stories.tsx | 64 +++++----- src/stories/Customization.stories.tsx | 89 ++++++++------ src/stories/DayView.stories.tsx | 48 +++++--- src/stories/Features.stories.tsx | 34 +++--- src/stories/MonthView.stories.tsx | 30 ++--- src/stories/QA/EdgeCases.stories.tsx | 114 +++++++++++++----- src/stories/QA/Interactions.stories.tsx | 17 ++- src/stories/QA/LayoutLimits.stories.tsx | 26 ++-- src/stories/QA/Performance.stories.tsx | 23 ++-- src/stories/QA/TimeFormatting.stories.tsx | 12 +- src/stories/QA/Views.stories.tsx | 12 +- src/stories/ScheduleView.stories.tsx | 76 ++++++------ src/stories/WeekView.stories.tsx | 74 +++++++----- src/types/calendar.ts | 2 +- src/utils/common.ts | 4 +- src/utils/date.test.ts | 8 +- src/utils/date.ts | 98 +++++++++------ src/utils/formatting.ts | 3 +- 50 files changed, 673 insertions(+), 427 deletions(-) diff --git a/package-lock.json b/package-lock.json index a5d5701..7721e65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "calendarize": "^1.1.1", "classnames": "^2.5.1", - "dayjs": "^1.11.19" + "luxon": "^3.7.2" }, "devDependencies": { "@chromatic-com/storybook": "^5.0.1", @@ -26,6 +26,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/luxon": "^3.7.1", "@types/node": "^25.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -3599,6 +3600,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -5529,12 +5537,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT" - }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -8955,6 +8957,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/package.json b/package.json index 388e9d2..3154154 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "dependencies": { "calendarize": "^1.1.1", "classnames": "^2.5.1", - "dayjs": "^1.11.19" + "luxon": "^3.7.2" }, "devDependencies": { "@chromatic-com/storybook": "^5.0.1", @@ -70,6 +70,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/luxon": "^3.7.1", "@types/node": "^25.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/src/Calendar.test.tsx b/src/Calendar.test.tsx index a6340f4..19e3222 100644 --- a/src/Calendar.test.tsx +++ b/src/Calendar.test.tsx @@ -55,7 +55,10 @@ describe("Calendar Component Integration", () => { it("responds to ControlledState updates", () => { const start = dateFn("2024-03-01"); const { rerender } = render( - , + , ); expect(screen.getByText("March 2024")).toBeInTheDocument(); @@ -63,7 +66,7 @@ describe("Calendar Component Integration", () => { const newDate = dateFn("2025-06-15"); rerender( , ); diff --git a/src/Calendar.tsx b/src/Calendar.tsx index fc26878..c14104a 100644 --- a/src/Calendar.tsx +++ b/src/Calendar.tsx @@ -143,7 +143,7 @@ function CalendarContent({ > {restProps.renderHeader ? ( restProps.renderHeader({ - currentDate: selectedDate.toDate(), + currentDate: selectedDate.toJSDate(), view, onNavigate: (date: Date) => { dispatch({ diff --git a/src/components/core/all_day_banner/AllDayBanner.test.tsx b/src/components/core/all_day_banner/AllDayBanner.test.tsx index f6c6647..78bae68 100644 --- a/src/components/core/all_day_banner/AllDayBanner.test.tsx +++ b/src/components/core/all_day_banner/AllDayBanner.test.tsx @@ -36,6 +36,15 @@ describe("AllDayBanner Component", () => { eventProps: {}, is12Hour: false, classNames: {}, + locale: "en", + localeMessages: { + today: "Today", + day: "Day", + week: "Week", + month: "Month", + schedule: "Schedule", + days: "Days", + }, }; it("renders empty state without failing", () => { diff --git a/src/components/core/current_time_line/CurrentTimeLine.tsx b/src/components/core/current_time_line/CurrentTimeLine.tsx index 455d1f0..d7e3b22 100644 --- a/src/components/core/current_time_line/CurrentTimeLine.tsx +++ b/src/components/core/current_time_line/CurrentTimeLine.tsx @@ -20,14 +20,14 @@ const CurrentTimeLine = ({ const { testId } = useCalendar(); const [position, setPosition] = useState(() => { const now = dateFn(); - return (now.hour() - minHour) * 60 + now.minute(); + return (now.hour - minHour) * 60 + now.minute; }); useEffect(() => { const updatePosition = () => { const now = dateFn(); - const hours = now.hour(); - const minutes = now.minute(); + const hours = now.hour; + const minutes = now.minute; // eventSlot height is 60px per hour const totalMinutes = (hours - minHour) * 60 + minutes; setPosition(totalMinutes); diff --git a/src/components/core/day_column/DayColumn.test.tsx b/src/components/core/day_column/DayColumn.test.tsx index 219d154..55ee210 100644 --- a/src/components/core/day_column/DayColumn.test.tsx +++ b/src/components/core/day_column/DayColumn.test.tsx @@ -27,6 +27,15 @@ describe("DayColumn Component", () => { dayEvents, is12Hour: false, classNames: {}, + locale: "en", + localeMessages: { + today: "Today", + day: "Day", + week: "Week", + month: "Month", + schedule: "Schedule", + days: "Days", + }, showCurrentTime: false, minHour: 0, maxHour: 24, diff --git a/src/components/core/day_event_item/DayWeekEventItem.test.tsx b/src/components/core/day_event_item/DayWeekEventItem.test.tsx index c812d84..6707902 100644 --- a/src/components/core/day_event_item/DayWeekEventItem.test.tsx +++ b/src/components/core/day_event_item/DayWeekEventItem.test.tsx @@ -26,6 +26,15 @@ describe("DayWeekEventItem Component", () => { item: mockItem, is12Hour: false, classNames: {}, + locale: "en", + localeMessages: { + today: "Today", + day: "Day", + week: "Week", + month: "Month", + schedule: "Schedule", + days: "Days", + }, }; it("renders the event item with correct styles and title", () => { diff --git a/src/components/core/month_event_item/MonthEventItem.test.tsx b/src/components/core/month_event_item/MonthEventItem.test.tsx index a1ce382..aaf9864 100644 --- a/src/components/core/month_event_item/MonthEventItem.test.tsx +++ b/src/components/core/month_event_item/MonthEventItem.test.tsx @@ -21,6 +21,15 @@ describe("MonthEventItem Component", () => { is12Hour: false, showAdjacentMonths: false, classNames: {}, + locale: "en", + localeMessages: { + today: "Today", + day: "Day", + week: "Week", + month: "Month", + schedule: "Schedule", + days: "Days", + }, }; it("renders date correctly and applies selected classes", () => { diff --git a/src/components/core/month_event_item/MonthEventItem.tsx b/src/components/core/month_event_item/MonthEventItem.tsx index 855d4ac..36ee658 100644 --- a/src/components/core/month_event_item/MonthEventItem.tsx +++ b/src/components/core/month_event_item/MonthEventItem.tsx @@ -113,7 +113,7 @@ function MonthEventItem({ <> {renderDateCell ? ( renderDateCell({ - date: dateObj.toDate(), + date: dateObj.toJSDate(), isToday, isSelected, isCurrentMonth, diff --git a/src/components/core/time_column/TimeColumn.test.tsx b/src/components/core/time_column/TimeColumn.test.tsx index 72ca019..581c533 100644 --- a/src/components/core/time_column/TimeColumn.test.tsx +++ b/src/components/core/time_column/TimeColumn.test.tsx @@ -13,7 +13,13 @@ describe("TimeColumn Component", () => { initialDate={dateFn()} initialView={ECalendarViewType.week} > - + , ); @@ -35,6 +41,7 @@ describe("TimeColumn Component", () => { maxHour={15} is12Hour={false} classNames={{}} + locale="en" /> , ); @@ -49,7 +56,13 @@ describe("TimeColumn Component", () => { initialDate={dateFn()} initialView={ECalendarViewType.week} > - + , ); diff --git a/src/components/core/time_column/TimeColumn.tsx b/src/components/core/time_column/TimeColumn.tsx index 075dbf8..8a29a8b 100644 --- a/src/components/core/time_column/TimeColumn.tsx +++ b/src/components/core/time_column/TimeColumn.tsx @@ -33,7 +33,7 @@ function TimeColumn({ const timeFormat = is12Hour ? DATE_FORMATS.HOUR_12H : DATE_FORMATS.TIME; return (
- {formatDate(dateFn().hour(hour).minute(0), timeFormat, locale)} + {formatDate(dateFn().set({ hour, minute: 0 }), timeFormat, locale)}
); })} diff --git a/src/components/layout/Header.test.tsx b/src/components/layout/Header.test.tsx index 3edb2c4..612dc31 100644 --- a/src/components/layout/Header.test.tsx +++ b/src/components/layout/Header.test.tsx @@ -38,6 +38,8 @@ describe("Header Component", () => { resetDateOnViewChange: false, onNavigate: vi.fn(), onViewChange: vi.fn(), + locale: "en", + localeMessages: {}, }; it("renders correctly with current date and view", () => { diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 5da753e..34fb08c 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -21,7 +21,6 @@ import { formatDate, getMonth, getYear, - ManipulateType, } from "../../utils"; import styles from "./Header.module.css"; import LeftArrow from "../../assets/LeftArrow"; @@ -72,19 +71,21 @@ function Header({ const isAdd = option === EMonthOption.add; dispatch({ type: isAdd ? CALENDAR_ACTIONS.NEXT : CALENDAR_ACTIONS.PREV }); - const unit = ( - view === ECalendarViewType.schedule ? "day" : view - ) as ManipulateType; + const unit = (view === ECalendarViewType.schedule ? "day" : view) as + | "day" + | "week" + | "month" + | "year"; let predictiveDate; if (view === ECalendarViewType.customDays) { predictiveDate = isAdd - ? selectedDate.add(customDays || 3, "day") - : selectedDate.subtract(customDays || 3, "day"); + ? selectedDate.plus({ day: customDays || 3 }) + : selectedDate.minus({ day: customDays || 3 }); } else { predictiveDate = isAdd - ? selectedDate.add(1, unit) - : selectedDate.subtract(1, unit); + ? selectedDate.plus({ [unit]: 1 }) + : selectedDate.minus({ [unit]: 1 }); } onNavigate?.(convertToDate(predictiveDate)); @@ -124,8 +125,8 @@ function Header({ if (view === ECalendarViewType.week) { const startOfWeek = selectedDate.startOf("week"); const endOfWeek = selectedDate.endOf("week"); - if (startOfWeek.month() !== endOfWeek.month()) { - if (startOfWeek.year() !== endOfWeek.year()) { + if (startOfWeek.month !== endOfWeek.month) { + if (startOfWeek.year !== endOfWeek.year) { return `${formatDate(startOfWeek, DATE_FORMATS.SHORT_MONTH_YEAR, locale)} - ${formatDate(endOfWeek, DATE_FORMATS.SHORT_MONTH_YEAR, locale)}`; } return `${formatDate(startOfWeek, DATE_FORMATS.SHORT_MONTH, locale)} - ${formatDate(endOfWeek, DATE_FORMATS.SHORT_MONTH_YEAR, locale)}`; @@ -133,9 +134,9 @@ function Header({ } if (view === ECalendarViewType.customDays) { const days = customDays || 3; - const endDate = selectedDate.add(days - 1, "day"); - if (selectedDate.month() !== endDate.month()) { - if (selectedDate.year() !== endDate.year()) { + const endDate = selectedDate.plus({ day: days - 1 }); + if (selectedDate.month !== endDate.month) { + if (selectedDate.year !== endDate.year) { return `${formatDate(selectedDate, DATE_FORMATS.SHORT_MONTH_YEAR, locale)} - ${formatDate(endDate, DATE_FORMATS.SHORT_MONTH_YEAR, locale)}`; } return `${formatDate(selectedDate, DATE_FORMATS.SHORT_MONTH, locale)} - ${formatDate(endDate, DATE_FORMATS.SHORT_MONTH_YEAR, locale)}`; @@ -143,7 +144,7 @@ function Header({ if (days === 1) { return formatDate(selectedDate, DATE_FORMATS.MONTH_DAY_YEAR, locale); } - return `${formatDate(selectedDate, DATE_FORMATS.DAY_DATE_SHORT_MONTH, locale)} - ${formatDate(endDate, DATE_FORMATS.DAY_DATE_SHORT_MONTH, locale)}, ${formatDate(selectedDate, "YYYY")}`; + return `${formatDate(selectedDate, DATE_FORMATS.DAY_DATE_SHORT_MONTH, locale)} - ${formatDate(endDate, DATE_FORMATS.DAY_DATE_SHORT_MONTH, locale)}, ${formatDate(selectedDate, "yyyy")}`; } if (view === ECalendarViewType.schedule) { if (events && events.length > 0) { @@ -153,15 +154,12 @@ function Header({ events.forEach((event) => { const sd = dateFn(event.startDate); const ed = event.endDate ? dateFn(event.endDate) : sd; - if (sd.isBefore(minDate)) minDate = sd; - if (ed.isAfter(maxDate)) maxDate = ed; + if (sd < minDate) minDate = sd; + if (ed > maxDate) maxDate = ed; }); - if ( - minDate.month() !== maxDate.month() || - minDate.year() !== maxDate.year() - ) { - if (minDate.year() !== maxDate.year()) { + if (minDate.month !== maxDate.month || minDate.year !== maxDate.year) { + if (minDate.year !== maxDate.year) { return `${formatDate(minDate, DATE_FORMATS.SHORT_MONTH_YEAR, locale)} - ${formatDate(maxDate, DATE_FORMATS.SHORT_MONTH_YEAR, locale)}`; } return `${formatDate(minDate, DATE_FORMATS.SHORT_MONTH, locale)} - ${formatDate(maxDate, DATE_FORMATS.SHORT_MONTH_YEAR, locale)}`; diff --git a/src/components/views/custom_days_view/CustomDaysView.test.tsx b/src/components/views/custom_days_view/CustomDaysView.test.tsx index b0c216a..07f0065 100644 --- a/src/components/views/custom_days_view/CustomDaysView.test.tsx +++ b/src/components/views/custom_days_view/CustomDaysView.test.tsx @@ -36,6 +36,15 @@ describe("CustomDaysView Component", () => { theme: {}, eventProps: {}, classNames: {}, + locale: "en", + localeMessages: { + today: "Today", + day: "Day", + week: "Week", + month: "Month", + schedule: "Schedule", + days: "Days", + }, showCurrentTime: true, maxEvents: 3, autoScrollToCurrentTime: false, diff --git a/src/components/views/custom_days_view/CustomDaysView.tsx b/src/components/views/custom_days_view/CustomDaysView.tsx index 8ffa78f..d40f05e 100644 --- a/src/components/views/custom_days_view/CustomDaysView.tsx +++ b/src/components/views/custom_days_view/CustomDaysView.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useEffect, useRef } from "react"; import cx from "classnames"; -import { dateFn, formatDate } from "../../../utils"; +import { getDayOfWeek, dateFn, formatDate } from "../../../utils"; import useDayEventLayout, { DayEventLayout, } from "../../../hooks/useDayEventLayout"; @@ -68,7 +68,7 @@ function CustomView({ const viewDays = useMemo(() => { return Array.from({ length: customDays }, (_, i) => - selectedDate.add(i, "day"), + selectedDate.plus({ day: i }), ); }, [selectedDate, customDays]); @@ -89,14 +89,14 @@ function CustomView({ const hasToday = useMemo(() => { const now = dateFn(); - return viewDays.some((day) => now.isSame(day, "day")); + return viewDays.some((day) => now.hasSame(day, "day")); }, [viewDays]); useEffect(() => { if (autoScrollToCurrentTime && containerRef.current && hasToday) { const now = dateFn(); - const hours = now.hour(); - const minutes = now.minute(); + const hours = now.hour; + const minutes = now.minute; const totalMinutes = hours * 60 + minutes; const container = containerRef.current; @@ -119,7 +119,7 @@ function CustomView({
{viewDays.map((date, index) => { - const isToday = dateFn().isSame(date, "day"); + const isToday = dateFn().hasSame(date, "day"); const todayStyle = isToday ? { color: theme?.today?.color, @@ -129,7 +129,7 @@ function CustomView({ return renderDateCell ? ( renderDateCell({ - date: date.toDate(), + date: date.toJSDate(), isToday, }) ) : ( @@ -138,7 +138,7 @@ function CustomView({ className={cx(styles.dayHeader, classNames?.dayHeader)} >
- {getDayListNames(dayType, locale)[date.day()]} + {getDayListNames(dayType, locale)[getDayOfWeek(date)]}
{viewDays.map((date, dayIndex) => { - const isToday = dateFn().isSame(date, "day"); + const isToday = dateFn().hasSame(date, "day"); return (
{ eventsAreSorted: false, isEventOrderingEnabled: false, classNames: {}, + locale: "en", + localeMessages: { + today: "Today", + day: "Day", + week: "Week", + month: "Month", + schedule: "Schedule", + days: "Days", + }, }; it("renders the day header correctly", () => { diff --git a/src/components/views/day_view/DayView.tsx b/src/components/views/day_view/DayView.tsx index a2100fd..f636c72 100644 --- a/src/components/views/day_view/DayView.tsx +++ b/src/components/views/day_view/DayView.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from "react"; import cx from "classnames"; -import { dateFn, formatDate } from "../../../utils"; +import { getDayOfWeek, dateFn, formatDate } from "../../../utils"; import useDayEventLayout, { DayEventLayout, } from "../../../hooks/useDayEventLayout"; @@ -78,7 +78,7 @@ function DayView({ }, ) as DayEventLayout[]; - const isToday = dateFn().isSame(selectedDate, "day"); + const isToday = dateFn().hasSame(selectedDate, "day"); const todayStyle = isToday ? { @@ -90,8 +90,8 @@ function DayView({ useEffect(() => { if (autoScrollToCurrentTime && containerRef.current && isToday) { const now = dateFn(); - const hours = now.hour(); - const minutes = now.minute(); + const hours = now.hour; + const minutes = now.minute; const totalMinutes = hours * 60 + minutes; const container = containerRef.current; @@ -115,13 +115,13 @@ function DayView({
{renderDateCell ? ( renderDateCell({ - date: selectedDate.toDate(), + date: selectedDate.toJSDate(), isToday, }) ) : (
- {getDayListNames(dayType, locale)[selectedDate.day()]} + {getDayListNames(dayType, locale)[getDayOfWeek(selectedDate)]}
{ theme: {}, eventProps: {}, classNames: {}, + locale: "en", + localeMessages: { + today: "Today", + day: "Day", + week: "Week", + month: "Month", + schedule: "Schedule", + days: "Days", + }, showAdjacentMonths: true, enableEnrichedEvents: false, eventsAreSorted: false, diff --git a/src/components/views/month_view/MonthView.tsx b/src/components/views/month_view/MonthView.tsx index e6c74fc..95b7c86 100644 --- a/src/components/views/month_view/MonthView.tsx +++ b/src/components/views/month_view/MonthView.tsx @@ -94,7 +94,7 @@ function MonthView({ const onClickDateHandler = useCallback( (dateInput: DateType) => { const newDate = dateFn(dateInput); - if (selectable && !newDate.isSame(selectedDate, "day")) { + if (selectable && !newDate.hasSame(selectedDate, "day")) { onDateClick?.(convertToDate(newDate)); dispatch({ type: "SET_DATE", payload: newDate }); } @@ -139,7 +139,7 @@ function MonthView({ isSelected={ selectable && dayInfo.isCurrentMonth && - dayInfo.displayDay === selectedDate.date() + dayInfo.displayDay === selectedDate.day } isToday={dayInfo.isToday} isCurrentMonth={dayInfo.isCurrentMonth} diff --git a/src/components/views/schedule_view/ScheduleView.test.tsx b/src/components/views/schedule_view/ScheduleView.test.tsx index cf0a135..67dcf23 100644 --- a/src/components/views/schedule_view/ScheduleView.test.tsx +++ b/src/components/views/schedule_view/ScheduleView.test.tsx @@ -42,6 +42,15 @@ describe("ScheduleView Component", () => { theme: {}, eventProps: {}, classNames: {}, + locale: "en", + localeMessages: { + today: "Today", + day: "Day", + week: "Week", + month: "Month", + schedule: "Schedule", + days: "Days", + }, autoScrollToCurrentTime: false, dayType: "half" as const, }; diff --git a/src/components/views/schedule_view/ScheduleView.tsx b/src/components/views/schedule_view/ScheduleView.tsx index 9d6bf72..44c1361 100644 --- a/src/components/views/schedule_view/ScheduleView.tsx +++ b/src/components/views/schedule_view/ScheduleView.tsx @@ -61,7 +61,7 @@ export default function ScheduleView({ const dateObj = dateFn(dateKey); const isLastGroup = groupIndex === allKeys.length - 1; - const isToday = checkIsToday(dateObj, dateObj.date()); + const isToday = checkIsToday(dateObj, dateObj.day); const todayStyle = isToday ? { color: theme?.today?.color, @@ -187,7 +187,7 @@ export default function ScheduleView({
{!isLastGroup && renderScheduleSeparator && - renderScheduleSeparator(dateObj.toDate())} + renderScheduleSeparator(dateObj.toJSDate())} ); }) diff --git a/src/components/views/week_view/WeekView.test.tsx b/src/components/views/week_view/WeekView.test.tsx index e716a43..492cae0 100644 --- a/src/components/views/week_view/WeekView.test.tsx +++ b/src/components/views/week_view/WeekView.test.tsx @@ -48,6 +48,15 @@ describe("WeekView Component", () => { eventsAreSorted: false, isEventOrderingEnabled: false, classNames: {}, + locale: "en", + localeMessages: { + today: "Today", + day: "Day", + week: "Week", + month: "Month", + schedule: "Schedule", + days: "Days", + }, }; it("renders the week headers correctly", () => { diff --git a/src/components/views/week_view/WeekView.tsx b/src/components/views/week_view/WeekView.tsx index c95e834..b3ab48e 100644 --- a/src/components/views/week_view/WeekView.tsx +++ b/src/components/views/week_view/WeekView.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useEffect, useRef } from "react"; import cx from "classnames"; -import { dateFn, formatDate } from "../../../utils"; +import { getDayOfWeek, dateFn, formatDate } from "../../../utils"; import useDayEventLayout, { DayEventLayout, } from "../../../hooks/useDayEventLayout"; @@ -68,18 +68,18 @@ function WeekView({ const { state, testId } = useCalendar(); const { selectedDate } = state; const startOfWeek = useMemo(() => { - const currentDay = selectedDate.day(); + const currentDay = getDayOfWeek(selectedDate); const diff = currentDay >= weekStartsOn ? weekStartsOn - currentDay : weekStartsOn - currentDay - 7; - return selectedDate.add(diff, "day").startOf("day"); + return selectedDate.plus({ day: diff }).startOf("day"); }, [selectedDate, weekStartsOn]); const weekDays = useMemo(() => { let length = weekEndsOn - weekStartsOn + 1; if (length <= 0) length += 7; - return Array.from({ length }, (_, i) => startOfWeek.add(i, "day")); + return Array.from({ length }, (_, i) => startOfWeek.plus({ day: i })); }, [startOfWeek, weekStartsOn, weekEndsOn]); const weekEvents = useDayEventLayout( @@ -99,14 +99,14 @@ function WeekView({ const isCurrentWeek = useMemo(() => { const now = dateFn(); - return weekDays.some((day) => now.isSame(day, "day")); + return weekDays.some((day) => now.hasSame(day, "day")); }, [weekDays]); useEffect(() => { if (autoScrollToCurrentTime && containerRef.current && isCurrentWeek) { const now = dateFn(); - const hours = now.hour(); - const minutes = now.minute(); + const hours = now.hour; + const minutes = now.minute; const totalMinutes = hours * 60 + minutes; const container = containerRef.current; @@ -129,7 +129,7 @@ function WeekView({
{weekDays.map((date, index) => { - const isToday = dateFn().isSame(date, "day"); + const isToday = dateFn().hasSame(date, "day"); const todayStyle = isToday ? { color: theme?.today?.color, @@ -139,7 +139,7 @@ function WeekView({ return renderDateCell ? ( renderDateCell({ - date: date.toDate(), + date: date.toJSDate(), isToday, }) ) : ( @@ -148,7 +148,7 @@ function WeekView({ className={cx(styles.dayHeader, classNames?.dayHeader)} >
- {getDayListNames(dayType, locale)[date.day()]} + {getDayListNames(dayType, locale)[getDayOfWeek(date)]}
{weekDays.map((date, dayIndex) => { - const isToday = dateFn().isSame(date, "day"); + const isToday = dateFn().hasSame(date, "day"); return (
- dateFn() - .day(i) - .locale(locale || "en") - .format(format), - ); + const format = dayType === EDayType.full ? "long" : "short"; + const days = Info.weekdays(format, { locale: locale || "en" }); + // Luxon returns Mon-Sun. We need Sun-Sat to match expected 0-6 index. + return [days[6], ...days.slice(0, 6)]; } export function getMonthList(locale?: string): MonthListType[] { - return Array.from({ length: 12 }, (_, i) => ({ - label: dateFn() - .month(i) - .locale(locale || "en") - .format("MMMM"), + return Info.months("long", { locale: locale || "en" }).map((label, i) => ({ + label, value: i, })); } @@ -70,19 +64,19 @@ export const defaultCalendarProps = { }; export const DATE_FORMATS = { - DATE: "YYYY-MM-DD", + DATE: "yyyy-MM-dd", TIME: "HH:mm", - TIME_12H: "hh:mm A", - HOUR_12H: "hh A", - MONTH_YEAR: "MMMM YYYY", - DAY_INDEX: "d", - DAY_NUMBER: "D", - FULL_DATE: "dddd, MMMM D, YYYY", - MONTH_DAY_YEAR: "MMMM D, YYYY", - SHORT_MONTH_YEAR: "MMM YYYY", + TIME_12H: "hh:mm a", + HOUR_12H: "hh a", + MONTH_YEAR: "MMMM yyyy", + DAY_INDEX: "c", + DAY_NUMBER: "d", + FULL_DATE: "EEEE, MMMM d, yyyy", + MONTH_DAY_YEAR: "MMMM d, yyyy", + SHORT_MONTH_YEAR: "MMM yyyy", SHORT_MONTH: "MMM", - SHORT_DAY: "ddd", - DAY_DATE_SHORT_MONTH: "ddd, D MMM", + SHORT_DAY: "EEE", + DAY_DATE_SHORT_MONTH: "EEE, d MMM", }; export const CALENDAR_ACTIONS = { diff --git a/src/context/CalendarContext.test.tsx b/src/context/CalendarContext.test.tsx index 5cb6cfe..5e16ae0 100644 --- a/src/context/CalendarContext.test.tsx +++ b/src/context/CalendarContext.test.tsx @@ -11,7 +11,9 @@ const TestComponent = () => { return (
{state.view} - {state.selectedDate.format("YYYY-MM-DD")} + + {state.selectedDate.toFormat("yyyy-MM-dd")} +
), @@ -154,7 +156,9 @@ export const CustomRenderers: Story = { diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 69c064d..0c3adae 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -193,10 +193,11 @@ function Header(props: HeaderProps) { className={cx(styles.header, finalHeaderClassName)} data-testid={`${testId}-header`} > -
+

{getHeaderTitle()}

-
+
onChange(e.target.checked)} + /> + + + ); +} + +function ColorControl({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) { + return ( +
+ onChange(e.target.value)} + /> + onChange(e.target.value)} + /> +
+ ); +} + +function Row({ + label, + sub, + children, +}: { + label: string; + sub?: boolean; + children: ReactNode; +}) { + return ( +
+ {label} +
{children}
+
+ ); +} + +function RangeRow({ + label, + min, + max, + value, + onChange, + unit = "", +}: { + label: string; + min: number; + max: number; + value: number; + onChange: (v: number) => void; + unit?: string; +}) { + return ( + + onChange(+e.target.value)} + /> + + {value} + {unit} + + + ); +} + +// ── Section wrapper ─────────────────────────────────────────────────────────── + +function Section({ + title, + open, + badge, + onToggle, + onReset, + children, +}: { + id: SectionId; + title: string; + open: boolean; + badge: number; + onToggle: () => void; + onReset: () => void; + children: ReactNode; +}) { + return ( +
+ + )} + + ▼ + + + {open &&
{children}
} +
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export function ControlPanel({ + onChange, + fixtureIndex, + onFixtureChange, +}: ControlPanelProps) { + const [state, setState] = useState(DEFAULTS); + const [openSections, setOpenSections] = useState>( + new Set(Object.keys(SECTION_KEYS) as SectionId[]), + ); + + const patch = (key: K, val: PanelState[K]) => { + setState((prev) => { + const next = { ...prev, [key]: val }; + onChange(toCalendarProps(next)); + return next; + }); + }; + + const resetSection = (id: SectionId) => { + setState((prev) => { + const next = { ...prev }; + for (const k of SECTION_KEYS[id]) { + (next as Record)[k] = DEFAULTS[k]; + } + onChange(toCalendarProps(next)); + return next; + }); + }; + + const resetAll = () => { + setState(DEFAULTS); + onChange(toCalendarProps(DEFAULTS)); + }; + + const toggleSection = (id: SectionId) => { + setOpenSections((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const s = state; + const sectionProps = (id: SectionId, title: string) => ({ + id, + title, + open: openSections.has(id), + badge: modifiedCount(s, SECTION_KEYS[id]), + onToggle: () => toggleSection(id), + onReset: () => resetSection(id), + }); + + return ( +
+
+ Props + +
+ +
+ {/* ── Events (fixture selector, not a CalendarProp) ── */} +
+
+ Events +
+
+ + + +
+
+ + {/* ── View ── */} +
+ + + + + patch("selectedDate", e.target.value)} + /> + + patch("customDays", v)} + /> + patch("weekStartsOn", v)} + /> + patch("weekEndsOn", v)} + /> + + patch("showAdjacentMonths", v)} + /> + + + patch("showWeekNumbers", v)} + /> + + + patch("showAllDayRow", v)} + /> + + + patch("resetDateOnViewChange", v)} + /> + +
+ + {/* ── Time Grid ── */} +
+ + + + + patch("is12Hour", v)} + /> + + patch("minHour", v)} + /> + patch("maxHour", v)} + /> + + patch("showCurrentTime", v)} + /> + + + patch("autoScrollToCurrentTime", v)} + /> + + patch("eventOverlapOffset", v)} + unit="%" + /> +
+ + {/* ── Year Picker ── */} +
+ + patch("pastYearLength", +e.target.value)} + /> + + + patch("futureYearLength", +e.target.value)} + /> + +
+ + {/* ── Interaction ── */} +
+ + patch("selectable", v)} + /> + + + patch("creatable", v)} + /> + + + + +
+ + {/* ── Appearance ── */} +
+ + + +
+ + patch("themeDefaultColor", v)} + /> + + + patch("themeDefaultBg", v)} + /> + + + patch("themeSelectedColor", v)} + /> + + + patch("themeSelectedBg", v)} + /> + + + patch("themeTodayColor", v)} + /> + + + patch("themeTodayBg", v)} + /> + +
+ + {/* ── Localization ── */} +
+ + + + + + +
+ + patch("msgToday", e.target.value)} + /> + + + patch("msgDay", e.target.value)} + /> + + + patch("msgWeek", e.target.value)} + /> + + + patch("msgMonth", e.target.value)} + /> + + + patch("msgSchedule", e.target.value)} + /> + + + patch("msgDays", e.target.value)} + /> + +
+ + {/* ── Loading ── */} +
+ + patch("isLoading", v)} + /> + + + patch("renderLoading", v)} + /> + +
+ + {/* ── Custom Renderers ── */} +
+ + patch("renderEvent", v)} + /> + + + patch("renderHeader", v)} + /> + + + patch("renderHourCell", v)} + /> + + + patch("renderDateCell", v)} + /> + + + patch("renderScheduleSeparator", v)} + /> + +
+ + {/* ── Performance ── */} +
+ + patch("enableEnrichedEvents", v)} + /> + + + patch("eventsAreSorted", v)} + /> + + + patch("isEventOrderingEnabled", v)} + /> + + + + +
+ + {/* ── Layout ── */} +
+ + + + {!["", "100%", "1280px", "1024px", "768px", "480px"].includes( + s.width, + ) && ( + + patch("width", e.target.value)} + /> + + )} + + + + {!["", "600px", "700px", "800px", "100%"].includes(s.height) && ( + + patch("height", e.target.value)} + /> + + )} +
+ + {/* ── Debug ── */} +
+ + patch("testId", e.target.value)} + /> + +
+
+
+ ); +} diff --git a/playground/src/TestFixtures.ts b/playground/src/TestFixtures.ts new file mode 100644 index 0000000..c53aefb --- /dev/null +++ b/playground/src/TestFixtures.ts @@ -0,0 +1,464 @@ +import type { CalendarEvent } from "calendar-simple"; + +const formatDateTime = (d: Date) => d.toISOString(); +const formatDate = (d: Date) => { + const timezoneOffset = d.getTimezoneOffset() * 60000; + return new Date(d.getTime() - timezoneOffset).toISOString().split("T")[0]; +}; +const addDays = (d: Date, days: number) => { + const result = new Date(d); + result.setDate(result.getDate() + days); + return result; +}; +const setTime = (d: Date, hour: number, minute: number = 0) => { + const result = new Date(d); + result.setHours(hour, minute, 0, 0); + return result; +}; + +export const edgeCaseEvents = (): CalendarEvent[] => { + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + ); + + return [ + { + id: "TC1", + startDate: formatDateTime(setTime(startOfToday, 9, 0)), + endDate: formatDateTime(setTime(startOfToday, 10, 0)), + title: "Standard Event", + style: { backgroundColor: "blue" }, + }, + { + id: "TC2", + startDate: formatDateTime(setTime(startOfToday, 10, 30)), + endDate: formatDateTime(setTime(startOfToday, 10, 30)), + title: "Zero Duration", + style: { backgroundColor: "red" }, + }, + { + id: "TC3", + startDate: formatDateTime(setTime(startOfToday, 12, 0)), + endDate: formatDateTime(setTime(startOfToday, 11, 0)), + title: "Negative Duration", + style: { backgroundColor: "orange" }, + }, + { + id: "TC4a", + startDate: formatDateTime(setTime(startOfToday, 13, 0)), + endDate: formatDateTime(setTime(startOfToday, 14, 0)), + title: "Completely Overlapping A", + style: { backgroundColor: "green" }, + }, + { + id: "TC4b", + startDate: formatDateTime(setTime(startOfToday, 13, 0)), + endDate: formatDateTime(setTime(startOfToday, 14, 0)), + title: "Completely Overlapping B", + style: { backgroundColor: "teal" }, + }, + { + id: "TC5a", + startDate: formatDateTime(setTime(startOfToday, 14, 30)), + endDate: formatDateTime(setTime(startOfToday, 15, 30)), + title: "Partially Overlapping A", + style: { backgroundColor: "purple" }, + }, + { + id: "TC5b", + startDate: formatDateTime(setTime(startOfToday, 15, 0)), + endDate: formatDateTime(setTime(startOfToday, 16, 0)), + title: "Partially Overlapping B", + style: { backgroundColor: "indigo" }, + }, + { + id: "TC6a", + startDate: formatDateTime(setTime(startOfToday, 16, 0)), + endDate: formatDateTime(setTime(startOfToday, 18, 0)), + title: "Outer Event", + style: { backgroundColor: "pink" }, + }, + { + id: "TC6b", + startDate: formatDateTime(setTime(startOfToday, 16, 30)), + endDate: formatDateTime(setTime(startOfToday, 17, 30)), + title: "Inner Event", + style: { backgroundColor: "rose" }, + }, + ...Array.from({ length: 5 }).map((_, i) => ({ + id: `TC7-${i}`, + startDate: formatDateTime(setTime(addDays(startOfToday, 1), 9, 0)), + endDate: formatDateTime(setTime(addDays(startOfToday, 1), 9, 30)), + title: `Short Event ${i + 1}`, + style: { backgroundColor: "gray" }, + })), + { + id: "TC8", + startDate: formatDateTime(setTime(addDays(startOfToday, 1), 22, 0)), + endDate: formatDateTime(setTime(addDays(startOfToday, 2), 2, 0)), + title: "Overnight Event (Datetime)", + style: { backgroundColor: "cyan" }, + }, + { + id: "TC9", + startDate: formatDateTime(setTime(addDays(startOfToday, 2), 23, 0)), + endDate: formatDateTime(setTime(addDays(startOfToday, 3), 1, 0)), + title: "Cross Midnight", + style: { backgroundColor: "sky" }, + }, + { + id: "TC10", + startDate: formatDateTime(setTime(startOfToday, 8, 0)), + title: "Missing End Time", + style: { backgroundColor: "violet" }, + }, + { + id: "TC11", + startDate: formatDate(addDays(startOfToday, 3)), + endDate: formatDate(addDays(startOfToday, 5)), + title: "Multi-Day Date Only", + style: { backgroundColor: "fuchsia" }, + }, + { + id: "TC12", + startDate: formatDate(addDays(startOfToday, 1)), + endDate: formatDate(addDays(startOfToday, 1)), + title: "Single-Day Date Only", + style: { backgroundColor: "magenta" }, + }, + { + id: "TC13", + startDate: formatDate(addDays(startOfToday, -1)), + title: "Missing End Date (Date Only)", + style: { backgroundColor: "lime" }, + }, + { + id: "TC14", + startDate: formatDate(addDays(today, -10)), + endDate: formatDate(addDays(today, 10)), + title: "Very Long Event (20 Days)", + style: { backgroundColor: "slate" }, + }, + { + id: "TC15", + startDate: formatDateTime(setTime(addDays(startOfToday, 4), 0, 0)), + endDate: formatDateTime(setTime(addDays(startOfToday, 4), 23, 59)), + title: "Full Day (Datetime)", + style: { backgroundColor: "emerald" }, + }, + { + id: "TC16", + startDate: formatDateTime(setTime(addDays(startOfToday, 0), 11, 0)), + endDate: formatDateTime(setTime(addDays(startOfToday, 2), 0, 1)), + title: "Day last second", + style: { backgroundColor: "amber" }, + }, + ]; +}; + +export const emptyEvents = (): CalendarEvent[] => []; + +export const singleTimedEvent = (): CalendarEvent[] => { + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + ); + return [ + { + id: "single-timed", + startDate: formatDateTime(setTime(startOfToday, 9, 0)), + endDate: formatDateTime(setTime(startOfToday, 10, 0)), + title: "Single Timed Event", + style: { backgroundColor: "#3b82f6" }, + }, + ]; +}; + +export const singleAllDayEvent = (): CalendarEvent[] => { + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + ); + return [ + { + id: "single-allday", + startDate: formatDate(startOfToday), + endDate: formatDate(startOfToday), + title: "Single All-Day Event", + style: { backgroundColor: "#10b981" }, + }, + ]; +}; + +export const allDayBannerStress = (): CalendarEvent[] => { + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + ); + const colors = [ + "#ef4444", + "#f97316", + "#eab308", + "#84cc16", + "#22c55e", + "#06b6d4", + "#0ea5e9", + "#6366f1", + ]; + return Array.from({ length: 8 }).map((_, i) => ({ + id: `allday-stress-${i}`, + startDate: formatDate(addDays(startOfToday, i % 4)), + endDate: formatDate(addDays(startOfToday, (i % 4) + 2)), + title: `Multi-Day Event ${i + 1}`, + style: { backgroundColor: colors[i] }, + })); +}; + +export const monthOverflow = (): CalendarEvent[] => { + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + ); + const colors = [ + "#e53e3e", + "#ed8936", + "#ecc94b", + "#48bb78", + "#38b2ac", + "#3182ce", + "#805ad5", + "#d69e2e", + "#d6bcfa", + "#fca5a5", + "#fed7aa", + "#fefce8", + ]; + return Array.from({ length: 12 }).map((_, i) => ({ + id: `overflow-${i}`, + startDate: formatDateTime(setTime(startOfToday, 8 + (i % 8), 0)), + endDate: formatDateTime(setTime(startOfToday, 9 + (i % 8), 0)), + title: `Event ${i + 1}`, + style: { backgroundColor: colors[i] }, + })); +}; + +export const largeDataset_1k = (): CalendarEvent[] => { + const today = new Date(); + const colors = [ + "#ef4444", + "#f97316", + "#eab308", + "#84cc16", + "#22c55e", + "#06b6d4", + "#0ea5e9", + "#6366f1", + ]; + return Array.from({ length: 1000 }).map((_, i) => { + const date = addDays(today, Math.floor(i / 10) - 50); + const hour = 8 + (i % 12); + return { + id: `event-1k-${i}`, + startDate: formatDateTime(setTime(date, hour, 0)), + endDate: formatDateTime(setTime(date, hour + 1, 0)), + title: `Event ${i}`, + style: { backgroundColor: colors[i % colors.length] }, + }; + }); +}; + +export const largeDataset_10k = (): CalendarEvent[] => { + const today = new Date(); + const colors = [ + "#ef4444", + "#f97316", + "#eab308", + "#84cc16", + "#22c55e", + "#06b6d4", + "#0ea5e9", + "#6366f1", + ]; + return Array.from({ length: 10000 }).map((_, i) => { + const date = addDays(today, Math.floor(i / 50) - 100); + const hour = 8 + (i % 12); + return { + id: `event-10k-${i}`, + startDate: formatDateTime(setTime(date, hour, 0)), + endDate: formatDateTime(setTime(date, hour + 1, 0)), + title: `Event ${i}`, + style: { backgroundColor: colors[i % colors.length] }, + }; + }); +}; + +export const dstSpring = (): CalendarEvent[] => [ + { + id: "dst-spring", + startDate: "2026-03-07T20:00:00", + endDate: "2026-03-08T04:00:00", + title: "Spans Spring DST", + style: { backgroundColor: "#06b6d4" }, + }, +]; +export const dstFall = (): CalendarEvent[] => [ + { + id: "dst-fall", + startDate: "2026-10-31T20:00:00", + endDate: "2026-11-01T04:00:00", + title: "Spans Fall DST", + style: { backgroundColor: "#f59e0b" }, + }, +]; +export const yearBoundary = (): CalendarEvent[] => [ + { + id: "year-boundary", + startDate: "2025-12-30T20:00:00", + endDate: "2026-01-02T02:00:00", + title: "Spans Year Boundary", + style: { backgroundColor: "#8b5cf6" }, + }, +]; +export const monthBoundary = (): CalendarEvent[] => [ + { + id: "month-boundary", + startDate: "2026-04-28T18:00:00", + endDate: "2026-05-03T02:00:00", + title: "Spans Month Boundary", + style: { backgroundColor: "#ec4899" }, + }, +]; + +export const unicodeTitles = (): CalendarEvent[] => { + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + ); + return [ + { + id: "unicode-arabic", + startDate: formatDateTime(setTime(startOfToday, 9, 0)), + endDate: formatDateTime(setTime(startOfToday, 10, 0)), + title: "اجتماع العمل العربي", + style: { backgroundColor: "#f59e0b" }, + }, + { + id: "unicode-chinese", + startDate: formatDateTime(setTime(startOfToday, 10, 0)), + endDate: formatDateTime(setTime(startOfToday, 11, 0)), + title: "中文会议标题", + style: { backgroundColor: "#ef4444" }, + }, + { + id: "unicode-emoji", + startDate: formatDateTime(setTime(startOfToday, 11, 0)), + endDate: formatDateTime(setTime(startOfToday, 12, 0)), + title: "🎉 Party Time 🎊", + style: { backgroundColor: "#a855f7" }, + }, + { + id: "unicode-rtl-ltr", + startDate: formatDateTime(setTime(startOfToday, 13, 0)), + endDate: formatDateTime(setTime(startOfToday, 14, 0)), + title: "English + العربية mixed", + style: { backgroundColor: "#06b6d4" }, + }, + { + id: "unicode-long", + startDate: formatDateTime(setTime(startOfToday, 14, 0)), + endDate: formatDateTime(setTime(startOfToday, 15, 0)), + title: + "This is a very long event title that exceeds 200 characters to test how the calendar handles wrapping and overflow in event chips across different views and screen sizes. It contains multiple sentences and words.", + style: { backgroundColor: "#10b981" }, + }, + ]; +}; + +export const customMetadata = (): CalendarEvent[] => { + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + ); + return [ + { + id: "custom-1", + startDate: formatDateTime(setTime(startOfToday, 9, 0)), + endDate: formatDateTime(setTime(startOfToday, 10, 0)), + title: "Meeting with team", + style: { backgroundColor: "#3b82f6" }, + category: "work", + attendees: ["Alice", "Bob", "Charlie"], + location: "Conference Room A", + priority: "high", + } as CalendarEvent, + { + id: "custom-2", + startDate: formatDateTime(setTime(startOfToday, 14, 0)), + endDate: formatDateTime(setTime(startOfToday, 15, 0)), + title: "Lunch with client", + style: { backgroundColor: "#10b981" }, + category: "personal", + attendees: ["Client X"], + location: "Downtown Restaurant", + priority: "medium", + } as CalendarEvent, + ]; +}; + +export const htmlInjection = (): CalendarEvent[] => { + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + ); + return [ + { + id: "html-injection", + startDate: formatDateTime(setTime(startOfToday, 9, 0)), + endDate: formatDateTime(setTime(startOfToday, 10, 0)), + title: '', + style: { backgroundColor: "#ef4444" }, + }, + { + id: "html-safe", + startDate: formatDateTime(setTime(startOfToday, 11, 0)), + endDate: formatDateTime(setTime(startOfToday, 12, 0)), + title: "Normal HTML should not render", + style: { backgroundColor: "#3b82f6" }, + }, + ]; +}; + +export const fixtureList: Array<{ name: string; fn: () => CalendarEvent[] }> = [ + { name: "Edge Cases", fn: edgeCaseEvents }, + { name: "Empty", fn: emptyEvents }, + { name: "Single Timed", fn: singleTimedEvent }, + { name: "Single All-Day", fn: singleAllDayEvent }, + { name: "All-Day Banner Stress", fn: allDayBannerStress }, + { name: "Month Overflow", fn: monthOverflow }, + { name: "Large Dataset (1k)", fn: largeDataset_1k }, + { name: "Large Dataset (10k)", fn: largeDataset_10k }, + { name: "DST Spring", fn: dstSpring }, + { name: "DST Fall", fn: dstFall }, + { name: "Year Boundary", fn: yearBoundary }, + { name: "Month Boundary", fn: monthBoundary }, + { name: "Unicode Titles", fn: unicodeTitles }, + { name: "Custom Metadata", fn: customMetadata }, + { name: "HTML Injection", fn: htmlInjection }, +]; From 52af87c15f099225bf5aabc6f7d8ac89200e8579 Mon Sep 17 00:00:00 2001 From: Jaganath M S Date: Mon, 11 May 2026 20:17:28 +0530 Subject: [PATCH 145/160] test: consolidate playwright playbook and results into single document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Result + Notes columns to all test matrices (A–L) with current run data - Merge 286 PASS, 4 FAIL, 19 N/A results from 2026-05-07 test session - Update "Results file" reference to point to this file instead of TEST_REPORT.md - Add TEST_REPORT.md to .gitignore (local-only artifact) - Document G-12, K-03, K-05, TC3 failures and cross-cutting findings PLAYWRIGHT_TEST_PLAN.md is now the single source of truth for both test spec and results. --- .gitignore | 7 +- PLAYWRIGHT_TEST_PLAN.md | 857 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 863 insertions(+), 1 deletion(-) create mode 100644 PLAYWRIGHT_TEST_PLAN.md diff --git a/.gitignore b/.gitignore index cdb5310..8f62e97 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,9 @@ docs storybook-static # Claude Code -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json +.playwright-mcp/ +tests/ + +# Local test reports (not tracked — results live in PLAYWRIGHT_TEST_PLAN.md) +TEST_REPORT.md \ No newline at end of file diff --git a/PLAYWRIGHT_TEST_PLAN.md b/PLAYWRIGHT_TEST_PLAN.md new file mode 100644 index 0000000..2dcc900 --- /dev/null +++ b/PLAYWRIGHT_TEST_PLAN.md @@ -0,0 +1,857 @@ +# Playwright Test Playbook — `calendar-simple` Library + +## About This Playbook + +This is the **canonical execution file** for every Playwright MCP test run against the `calendar-simple` library. Load this file as context at the start of each test session, then execute sweeps A–L in order. + +- **Target app**: `http://localhost:5173` — start with `cd playground && npm run dev` +- **Test harness**: MCP Playwright plugin — all 23 `mcp__plugin_playwright_playwright__*` tools +- **testId prefix**: `playground-calendar` (set in `playground/src/App.tsx:35`) +- **Results file**: results are tracked in this file (PLAYWRIGHT_TEST_PLAN.md) +- **Screenshots**: save to `tests/screenshots/` using names from the Visual Regression section +- **Total cases**: 310 (A:18 + B:14 + C:128 + D:26 + E:18 + F:18 + G:24 + H:13 + I:14 + J:14 + K:15 + L:8) +- **Library version**: `calendar-simple` v1.2.0 — branch `version_2` + +--- + +## Known Issues + +> Check this list at the start of each run. Rows marked **OPEN** have confirmed bugs — record FAIL in this file; do not skip them. + +| Status | ID | Test | Observed | Expected | Repro | +| ------ | ---- | ------------------------------------------ | ----------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| OPEN | G-12 | `onMoreClick(date, hiddenEvents)` | Second argument is always `undefined` | Should receive `CalendarEvent[]` of hidden events | Month view + `maxEvents=2` + "Month Overflow" fixture → click "+10 more" → check console | +| OPEN | K-03 | `eventsAreSorted=true` with unsorted input | Events render in wrong visual order; no warning | Should warn or document caveat prominently | "Large Dataset (1k)" fixture → toggle `eventsAreSorted=true` with unordered input | +| OPEN | K-05 | `enableEnrichedEvents=true` without map | Silently falls back to flat array; no warning | Should log a warning that map is missing | ControlPanel → Performance → `enableEnrichedEvents=true`, no `enrichedEventsByDate` | +| OPEN | TC3 | Negative-duration events | Correctly filtered (not rendered) | `console.warn` expected in dev mode | "Edge Cases" fixture → TC3 "Negative Duration" → check console | + +--- + +## Session Initialization + +Run these steps at the **start of every test session** before any sweep. + +### Step 1 — Start Dev Server + +``` +cd playground && npm run dev # keep running in terminal; port 5173 +``` + +### Step 2 — Open Browser & Baseline + +``` +browser_navigate(url="http://localhost:5173") +browser_wait_for(text="playground-calendar-container") +browser_snapshot() → verify 2-panel layout: calendar left, ControlPanel right sidebar +browser_console_messages() → MUST be empty (zero errors/warnings) +browser_network_requests() → all assets return 200 +``` + +### Step 3 — Reset ControlPanel + +``` +browser_snapshot() → locate "Reset All" button at top of ControlPanel sidebar +browser_click(element="Reset All") +browser_snapshot() → confirm all section badges read 0 (no modified controls) +``` + +### Step 4 — Verify Root testId + +``` +browser_evaluate(script="!!document.querySelector('[data-testid=\"playground-calendar-container\"]')") +→ must return true +``` + +--- + +## Common Selectors & MCP Command Reference + +### Calendar testId Selectors (prefix = `playground-calendar`) + +All selectors use `playground-calendar` as the testId prefix. Source: `Header.tsx:194-287`, `Calendar.tsx:74`, `Popover.tsx:172-222`, `ScheduleView.tsx:61-130`. + +| Element | Selector | +| --------------------- | -------------------------------------------------------------- | +| Root container | `[data-testid="playground-calendar-container"]` | +| Header bar | `[data-testid="playground-calendar-header"]` | +| Today button | `[data-testid="playground-calendar-header-today-btn"]` | +| Previous button | `[data-testid="playground-calendar-header-prev-btn"]` | +| Next button | `[data-testid="playground-calendar-header-next-btn"]` | +| View select | `[data-testid="playground-calendar-header-view-select"]` | +| Month select | `[data-testid="playground-calendar-header-month-select"]` | +| Year select | `[data-testid="playground-calendar-header-year-select"]` | +| Month view wrapper | `[data-testid="playground-calendar-month-view"]` | +| Schedule view wrapper | `[data-testid="playground-calendar-schedule-view"]` | +| Popover dialog | `[data-testid="playground-calendar-popover-content"]` | +| Popover event item | `[data-testid="playground-calendar-{eventId}-popover-item"]` | +| Schedule event | `[data-testid="playground-calendar-{eventId}-schedule-event"]` | + +### ARIA Selectors + +| Element | Selector | +| --------------- | ------------------------------------------------- | +| Header nav | `nav[aria-label="Calendar navigation"]` | +| Today button | `button[aria-label="Today"]` (or localized value) | +| Previous | `button[aria-label="Previous period"]` | +| Next | `button[aria-label="Next period"]` | +| View select | `select[aria-label="Select calendar view"]` | +| Month select | `select[aria-label="Select month"]` | +| Year select | `select[aria-label="Select year"]` | +| Popover | `[role="dialog"][aria-modal="true"]` | +| Schedule region | `[role="region"][aria-label="Schedule view"]` | + +### Data Attribute Checks + +```javascript +// Color scheme resolved value +browser_evaluate( + (script = + "document.querySelector('[data-testid=\"playground-calendar-container\"]').dataset.colorScheme"), +); + +// Direction attribute (ltr / rtl) +browser_evaluate( + (script = + "document.querySelector('[data-testid=\"playground-calendar-container\"]').dir"), +); + +// Calendar width CSS var +browser_evaluate( + (script = + "getComputedStyle(document.querySelector('[data-testid=\"playground-calendar-container\"]')).getPropertyValue('--calendar-width')"), +); +``` + +### Common MCP Operations + +``` +# Navigation +browser_navigate(url="http://localhost:5173") +browser_click(element="Today") → by aria-label +browser_click(element="Previous period") +browser_click(element="Next period") + +# View switching (via header dropdown) +browser_select_option(element="Select calendar view", values=["month"]) +# values: month | week | day | schedule | customDays + +# Month / Year dropdowns +browser_select_option(element="Select month", values=["12"]) → December +browser_select_option(element="Select year", values=["2030"]) + +# Keyboard interaction +browser_press_key(key="Escape") +browser_press_key(key="Tab") +browser_press_key(key="Enter") +browser_press_key(key="Space") + +# Screenshots +browser_take_screenshot(filename="tests/screenshots/.png") + +# Console & network hygiene +browser_console_messages() +browser_network_requests() + +# Viewport resize +browser_resize(width=1280, height=800) → desktop +browser_resize(width=768, height=900) → tablet +browser_resize(width=480, height=800) → phone +browser_resize(width=360, height=640) → narrow phone + +# DOM evaluation +browser_evaluate(script="") + +# Wait for element / text +browser_wait_for(text="") +``` + +--- + +## ControlPanel Quick-Action Guide + +The ControlPanel sidebar has 12 collapsible sections. **No `data-testid` attributes** exist on ControlPanel elements — always `browser_snapshot()` first, then target by visible label text or accessible name. + +### Standard Pattern + +``` +browser_snapshot() → read accessibility tree +browser_click(element="