diff --git a/docs/framework/react/reference/useCalendar.md b/docs/framework/react/reference/useCalendar.md new file mode 100644 index 0000000..0582268 --- /dev/null +++ b/docs/framework/react/reference/useCalendar.md @@ -0,0 +1,230 @@ +--- +title: Use Calendar +id: useCalendar +--- + +### `useCalendar` + +```tsx +export function useCalendar({ + weekStartsOn, + events, + viewMode, + locale, + onChangeViewMode, +}: UseCalendarProps) +``` + +`useCalendar` is a hook that provides a comprehensive set of functionalities for managing calendar events, view modes, and period navigation. + + +#### Parameters + +- `weekStartsOn?: number` + - This parameter is an optional number that specifies the day of the week that the calendar should start on. It defaults to 0, which is Sunday. +- `events: Event[]` + - This parameter is an array of events that the calendar should display. +- `viewMode: 'month' | 'week' | number` + - This parameter is a string that specifies the initial view mode of the calendar. It can be either 'month', 'week', or a number representing the number of days in a custom view mode. +- `locale?: string` + - This parameter is an optional string that specifies the locale to use for formatting dates and times. It defaults to the system locale. +- `onChangeViewMode?: ({ value: number; unit: "month" | "week" | "day"; }) => void` + - This parameter is an optional callback function that is called when the view mode of the calendar changes. It receives the new view mode as an argument. +- `onChangeViewMode?: (viewMode: value: number; unit: "month" | "week" | "day";) => void` + - This parameter is an optional callback function that is called when the view mode of the calendar changes. It receives the new view mode as an argument. +- `reducer?: (state: CalendarState, action: CalendarAction) => CalendarState` + - This parameter is an optional custom reducer function that can be used to manage the state of the calendar. + + +#### Returns + +- `firstDayOfPeriod: Temporal.PlainDate` + - This value represents the first day of the current period displayed by the calendar. +- `currentPeriod: string` + - This value represents a string that describes the current period displayed by the calendar. +- `goToPreviousPeriod: MouseEventHandler` + - This function is a click event handler that navigates to the previous period. +- `goToNextPeriod: MouseEventHandler` + - This function is a click event handler that navigates to the next period. +- `goToCurrentPeriod: MouseEventHandler` + - This function is a click event handler that navigates to the current period. +- `goToSpecificPeriod: (date: Temporal.PlainDate) => void` + - This function is a callback function that is called when a date is selected in the calendar. It receives the selected date as an argument. +- `days: Day[]` + - This value represents an array of days in the current period displayed by the calendar. +- `daysNames: string[]` + - This value represents an array of strings that contain the names of the days of the week. +- `viewMode: 'month' | 'week' | number` + - This value represents the current view mode of the calendar. +- `changeViewMode: (newViewMode: 'month' | 'week' | number) => void` + - This function is used to change the view mode of the calendar. +- `getEventProps: (id: string) => { style: CSSProperties } | null` + - This function is used to retrieve the style properties for a specific event based on its ID. +- `getEventProps: (id: string) => { style: CSSProperties } | null` + - This function is used to retrieve the style properties for a specific event based on its ID. +- `getEventProps: (id: string) => { style: CSSProperties } | null` + - This function is used to retrieve the style properties for a specific event based on its ID. +- `getCurrentTimeMarkerProps: () => { style: CSSProperties, currentTime: Temporal.PlainTime }` + - This function is used to retrieve the style properties and current time for the current time marker. +- `isPending: boolean` + - This value represents whether the calendar is in a pending state. +- `groupDaysBy: ({ days: Day[], unit: 'week' | 'month', fillMissingDays?: boolean }) => Day[][]` + - This function is used to group the days in the current period by a specified unit. The `fillMissingDays` parameter can be used to fill in missing days with previous or next month's days. + +#### Example Usage + +```tsx +const CalendarComponent = ({ events }) => { + const { + firstDayOfPeriod, + currentPeriod, + goToPreviousPeriod, + goToNextPeriod, + goToCurrentPeriod, + goToSpecificPeriod, + changeViewMode, + days, + daysNames, + viewMode, + getEventProps, + getCurrentTimeMarkerProps, + groupDaysBy, + } = useCalendar({ + weekStartsOn: 1, + viewMode: { value: 1, unit: 'month' }, + locale: 'en-US', + onChangeViewMode: (newViewMode) => console.log('View mode changed:', newViewMode), + }); + + return ( +
+
+ + + +
+
+ + + + +
+ + {viewMode.unit === 'month' && ( + groupDaysBy(days, 'months').map((month, monthIndex) => ( + + + + + + {daysNames.map((dayName, index) => ( + + ))} + + {groupDaysBy(month, 'weeks').map((week, weekIndex) => ( + + {week.map((day) => ( + + ))} + + ))} + + )) + )} + + {viewMode.unit === 'week' && ( + + + {daysNames.map((dayName, index) => ( + + ))} + + {groupDaysBy(days, 'weeks').map((week, weekIndex) => ( + + {week.map((day) => ( + + ))} + + ))} + + )} + + {viewMode.unit === 'day' && ( + + + {daysNames.map((dayName, index) => ( + + ))} + + + {days.map((day) => ( + + ))} + + + )} +
+ {month[0]?.date.toLocaleString('default', { month: 'long' })}{' '} + {month[0]?.date.year} +
+ {dayName} +
+
{day.date.day}
+
+ {day.events.map((event) => ( +
+ {event.title} +
+ ))} +
+
+ {dayName} +
+
{day.date.day}
+
+ {day.events.map((event) => ( +
+ {event.title} +
+ ))} +
+
+ {dayName} +
+
{day.date.day}
+
+ {day.events.map((event) => ( +
+ {event.title} +
+ ))} +
+
+
+ ); +}; +``` diff --git a/docs/framework/react/reference/useStore.md b/docs/framework/react/reference/useStore.md deleted file mode 100644 index 0a709c5..0000000 --- a/docs/framework/react/reference/useStore.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: Use Store -id: useStore ---- -### `useStore` - -```tsx -export function useStore< - TState, - TSelected = NoInfer, - TUpdater extends AnyUpdater = AnyUpdater, ->( - store: Store, - selector: (state: NoInfer) => TSelected = (d) => d as any, -) -``` - -useStore is a custom hook that returns the updated store given the intial store and the selector. React can use this to keep your component subscribed to the store and re-render it on changes. - - - -#### Parameters -- `store: Store` - - This parameter represents the external store itself which holds the entire state of your application. It expects an instance of a `@tanstack/store` that manages state and supports updates through a callback. -- `selector: (state: NoInfer) => TSelected = (d) => d as any` - - This parameter is a callback function that takes the state of the store and expects you to return a sub-state of the store. This selected sub-state is subsequently utilized as the state displayed by the useStore hook. It triggers a re-render of the component only when there are changes in this data, ensuring that updates to the displayed state trigger the necessary re-renders - - diff --git a/docs/reference/calendar-core.md b/docs/reference/calendar-core.md new file mode 100644 index 0000000..22e4b52 --- /dev/null +++ b/docs/reference/calendar-core.md @@ -0,0 +1,113 @@ +--- +title: Calendar Core +id: calendar-core +--- + +### `CalendarCore` + +```tsx +export class CalendarCore { + constructor(options: CalendarCoreOptions); +} +``` + +The `CalendarCore` class provides a set of functionalities for managing calendar events, view modes, and period navigation. This class is designed to be used in various calendar applications where precise date management and event handling are required. + + +#### Parameters + +- `weekStartsOn?: number` + - An optional number that specifies the day of the week that the calendar should start on. It defaults to 1 (Monday). +- `events?: TEvent[]` + - An optional array of events that the calendar should display. +- `viewMode: ViewMode` + - An object that specifies the initial view mode of the calendar. +- `locale?: Parameters['0']` + - An optional string that specifies the locale to use for formatting dates and times. + + +#### Returns + +- `getDaysWithEvents(): Array>` + - Returns an array of days in the current period with their associated events. +- `getDaysNames(): string[]` + - Returns an array of strings representing the names of the days of the week based on the locale and week start day. +- `changeViewMode(newViewMode: ViewMode): void` + - Changes the view mode of the calendar. +- `goToPreviousPeriod(): void` + - Navigates to the previous period based on the current view mode. +- `goToNextPeriod(): void` + - Navigates to the next period based on the current view mode. +- `goToCurrentPeriod(): void` + - Navigates to the current period. +- `goToSpecificPeriod(date: Temporal.PlainDate): void` + - Navigates to a specific period based on the provided date. +- `updateCurrentTime(): void` + - Updates the current time. +- `getEventProps(id: Event['id']): { style: CSSProperties } | null` + - Retrieves the style properties for a specific event based on its ID. +- `getCurrentTimeMarkerProps(): { style: CSSProperties; currentTime: string | undefined }` + - Retrieves the style properties and current time for the current time marker. +- `groupDaysBy(props: Omit, 'weekStartsOn'>): (Day | null)[][]` + - Groups the days in the current period by a specified unit. The fillMissingDays parameter can be used to fill in missing days with previous or next month's days. + + +#### Example Usage + +```ts +import { CalendarCore, Event } from '@tanstack/time'; +import { Temporal } from '@js-temporal/polyfill'; + +interface MyEvent extends Event { + location: string; +} + +const events: MyEvent[] = [ + { + id: '1', + startDate: Temporal.PlainDateTime.from('2024-06-10T09:00'), + endDate: Temporal.PlainDateTime.from('2024-06-10T10:00'), + title: 'Event 1', + location: 'Room 101', + }, + { + id: '2', + startDate: Temporal.PlainDateTime.from('2024-06-12T11:00'), + endDate: Temporal.PlainDateTime.from('2024-06-12T12:00'), + title: 'Event 2', + location: 'Room 202', + }, +]; + +const calendarCore = new CalendarCore({ + weekStartsOn: 1, + viewMode: { value: 1, unit: 'month' }, + events, + locale: 'en-US', +}); + +// Get days with events +const daysWithEvents = calendarCore.getDaysWithEvents(); +console.log(daysWithEvents); + +// Change view mode to week +calendarCore.changeViewMode({ value: 1, unit: 'week' }); + +// Navigate to the next period +calendarCore.goToNextPeriod(); + +// Update current time +calendarCore.updateCurrentTime(); + +// Get event properties +const eventProps = calendarCore.getEventProps('1'); +console.log(eventProps); + +// Get current time marker properties +const currentTimeMarkerProps = calendarCore.getCurrentTimeMarkerProps(); +console.log(currentTimeMarkerProps); + +// Group days by week +const groupedDays = calendarCore.groupDaysBy({ days: daysWithEvents, unit: 'week' }); +console.log(groupedDays); +``` diff --git a/docs/reference/date-picker-core.md b/docs/reference/date-picker-core.md new file mode 100644 index 0000000..184a234 --- /dev/null +++ b/docs/reference/date-picker-core.md @@ -0,0 +1,109 @@ +--- +title: DatePicker Core +id: date-picker-core +--- + +### `CalendarCore` +```ts +export class DatePickerCore extends CalendarCore { + constructor(options: DatePickerCoreOptions); +} +``` + +The `DatePicker` class extends `CalendarCore` to provide additional functionalities for managing date selection, including single-date, multiple-date, and range selection modes. This class is designed to be used in various date picker applications where precise date management and selection are required. + +#### Parameters + +- `weekStartsOn?: number` + - An optional number that specifies the day of the week that the calendar should start on. It defaults to 1 (Monday). +- `events?: TEvent[]` + - An optional array of events that the calendar should display. +- `viewMode: ViewMode` + - An object that specifies the initial view mode of the calendar. +- `locale?: Parameters['0']` + - An optional string that specifies the locale to use for formatting dates and times. +- `minDate?: Temporal.PlainDate` + - An optional date that specifies the minimum selectable date. +- `maxDate?: Temporal.PlainDate` + - An optional date that specifies the maximum selectable date. +- `selectedDates?: Temporal.PlainDate[]` + - An optional array of dates that are initially selected. +- `multiple?: boolean` + - An optional boolean that allows multiple dates to be selected if set to true. +- `range?: boolean` + - An optional boolean that allows a range of dates to be selected if set to true. + +#### Returns + +- `getSelectedDates(): Temporal.PlainDate[]` + - Returns an array of selected dates. +- `selectDate(date: Temporal.PlainDate): void` + - Selects a date. Depending on the configuration, this can handle single, multiple, or range selections. +- `getDaysWithEvents(): Array>` + - Returns an array of days in the current period with their associated events. +- `getDaysNames(): string[]` + - Returns an array of strings representing the names of the days of the week based on the locale and week start day. +- `changeViewMode(newViewMode: ViewMode): void` + - Changes the view mode of the calendar. +- `goToPreviousPeriod(): void` + - Navigates to the previous period based on the current view mode. +- `goToNextPeriod(): void` + - Navigates to the next period based on the current view mode. +- `goToCurrentPeriod(): void` + - Navigates to the current period. +- `goToSpecificPeriod(date: Temporal.PlainDate): void` + - Navigates to a specific period based on the provided date. +- `updateCurrentTime(): void` + - Updates the current time. +- `getEventProps(id: Event['id']): { style: CSSProperties } | null` + - Retrieves the style properties for a specific event based on its ID. +- `getCurrentTimeMarkerProps(): { style: CSSProperties; currentTime: string | undefined }` + - Retrieves the style properties and current time for the current time marker. +- `groupDaysBy(props: Omit, 'weekStartsOn'>): (Day | null)[][]` + - Groups the days in the current period by a specified unit. The fillMissingDays parameter can be used to fill in missing days with previous or next month's days. + + +#### Example Usage +```ts +import { DatePickerCore, Event } from '@tanstack/time'; +import { Temporal } from '@js-temporal/polyfill'; + +const datePickerCore = new DatePickerCore({ + weekStartsOn: 1, + viewMode: { value: 1, unit: 'month' }, + events, + locale: 'en-US', + minDate: Temporal.PlainDate.from('2024-01-01'), + maxDate: Temporal.PlainDate.from('2024-12-31'), + selectedDates: [Temporal.PlainDate.from('2024-06-10')], + multiple: true, +}); + +// Get selected dates +const selectedDates = datePickerCore.getSelectedDates(); +console.log(selectedDates); + +// Select a date +datePickerCore.selectDate(Temporal.PlainDate.from('2024-06-15')); + +// Change view mode to week +datePickerCore.changeViewMode({ value: 1, unit: 'week' }); + +// Navigate to the next period +datePickerCore.goToNextPeriod(); + +// Update current time +datePickerCore.updateCurrentTime(); + +// Get event properties +const eventProps = datePickerCore.getEventProps('1'); +console.log(eventProps); + +// Get current time marker properties +const currentTimeMarkerProps = datePickerCore.getCurrentTimeMarkerProps(); +console.log(currentTimeMarkerProps); + +// Group days by week +const groupedDays = datePickerCore.groupDaysBy({ days: daysWithSelection, unit: 'week' }); +console.log(groupedDays); +``` diff --git a/packages/react-time/package.json b/packages/react-time/package.json index c3f9d13..84a9a61 100644 --- a/packages/react-time/package.json +++ b/packages/react-time/package.json @@ -62,7 +62,10 @@ "react-dom": "^17.0.0 || ^18.0.0" }, "dependencies": { + "@js-temporal/polyfill": "^0.4.4", + "@tanstack/react-store": "^0.4.1", "@tanstack/time": "workspace:*", + "typesafe-actions": "^5.1.0", "use-sync-external-store": "^1.2.0" }, "devDependencies": { diff --git a/packages/react-time/src/index.ts b/packages/react-time/src/index.ts index dabac73..0b95ae9 100644 --- a/packages/react-time/src/index.ts +++ b/packages/react-time/src/index.ts @@ -1,3 +1 @@ -/** - * TanStack Time - */ \ No newline at end of file +export { useCalendar } from './useCalendar'; diff --git a/packages/react-time/src/tests/useCalendar.test.tsx b/packages/react-time/src/tests/useCalendar.test.tsx new file mode 100644 index 0000000..ba20a20 --- /dev/null +++ b/packages/react-time/src/tests/useCalendar.test.tsx @@ -0,0 +1,351 @@ +import { Temporal } from '@js-temporal/polyfill' +import { describe, expect, test, vi } from 'vitest' +import { act, renderHook, waitFor } from '@testing-library/react' +import { useCalendar } from '../useCalendar' + +describe('useCalendar', () => { + const events = [ + { + id: '1', + startDate: Temporal.PlainDateTime.from('2024-06-01T10:00:00'), + endDate: Temporal.PlainDateTime.from('2024-06-01T12:00:00'), + title: 'Event 1', + }, + { + id: '2', + startDate: Temporal.PlainDateTime.from('2024-06-02T14:00:00'), + endDate: Temporal.PlainDateTime.from('2024-06-02T16:00:00'), + title: 'Event 2', + }, + ] + + test('should initialize with the correct view mode and current period', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' } }), + ) + expect(result.current.viewMode).toEqual({ value: 1, unit: 'month' }) + expect(result.current.currentPeriod.toString()).toBe( + Temporal.Now.plainDateISO().toString(), + ) + }) + + test('should navigate to the previous period correctly', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' } }), + ) + + act(() => { + result.current.goToPreviousPeriod() + }) + + const expectedPreviousMonth = Temporal.Now.plainDateISO().subtract({ + months: 1, + }) + + expect(result.current.currentPeriod).toEqual( + expectedPreviousMonth, + ) + }) + + test('should navigate to the next period correctly', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' } }), + ) + + act(() => { + result.current.goToNextPeriod() + }) + + const expectedNextMonth = Temporal.Now.plainDateISO().add({ months: 1 }) + + expect(result.current.currentPeriod).toEqual(expectedNextMonth) + }) + + test('should change view mode correctly', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' } }), + ) + + act(() => { + result.current.changeViewMode({ value: 1, unit: 'week' }) + }) + + expect(result.current.viewMode).toEqual({ value: 1, unit: 'week' }) + }) + + test('should select a day correctly', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' } }), + ) + + act(() => { + result.current.goToSpecificPeriod(Temporal.PlainDate.from('2024-06-01')) + }) + + expect(result.current.currentPeriod.toString()).toBe('2024-06-01') + }) + + test('should return the correct props for an event', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'week' } }), + ) + + const eventProps = result.current.getEventProps('1') + + expect(eventProps).toEqual({ + style: { + position: 'absolute', + top: 'min(41.66666666666667%, calc(100% - 55px))', + left: '2%', + width: '96%', + margin: 0, + height: '8.333333333333332%', + }, + }) + }) + + test('should return the correct props for overlapping events', () => { + const overlappingEvents = [ + { + id: '1', + startDate: Temporal.PlainDateTime.from('2024-06-01T10:00:00'), + endDate: Temporal.PlainDateTime.from('2024-06-01T12:00:00'), + title: 'Event 1', + }, + { + id: '2', + startDate: Temporal.PlainDateTime.from('2024-06-01T11:00:00'), + endDate: Temporal.PlainDateTime.from('2024-06-01T13:00:00'), + title: 'Event 2', + }, + ] + const { result } = renderHook(() => + useCalendar({ events: overlappingEvents, viewMode: { value: 1, unit: 'week' } }), + ) + + const event1Props = result.current.getEventProps('1') + const event2Props = result.current.getEventProps('2') + + expect(event1Props).toEqual({ + style: { + position: 'absolute', + top: 'min(41.66666666666667%, calc(100% - 55px))', + left: '2%', + width: '47%', + margin: 0, + height: '8.333333333333332%', + }, + }) + + expect(event2Props).toEqual({ + style: { + position: 'absolute', + top: 'min(45.83333333333333%, calc(100% - 55px))', + left: '51%', + width: '47%', + margin: 0, + height: '8.333333333333332%', + }, + }) + }) + + test('should return the correct props for the current time marker', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-01T11:00:00')); + const { result } = renderHook(() => + useCalendar({ viewMode: { value: 1, unit: 'week' } }), + ); + + const getCurrentTimeMarkerProps = result.current.getCurrentTimeMarkerProps(); + + expect(getCurrentTimeMarkerProps).toEqual({ + style: { + position: 'absolute', + top: '45.83333333333333%', + left: 0, + }, + currentTime: '11:00', + }); + }); + + test('should update the current time marker props after time passes', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-01T11:00:00')); + const { result } = renderHook(() => + useCalendar({ viewMode: { value: 1, unit: 'week' } }), + ); + + const currentTimeMarkerProps = result.current.getCurrentTimeMarkerProps(); + + expect(currentTimeMarkerProps).toEqual({ + style: { + position: 'absolute', + top: '45.83333333333333%', + left: 0, + }, + currentTime: '11:00', + }); + + act(() => { + vi.advanceTimersByTime(60000); + }); + + waitFor(() => { + expect(currentTimeMarkerProps).toEqual({ + style: { + position: 'absolute', + top: '45.90277777777778%', + left: 0, + }, + currentTime: '11:01', + }); + }); + }); + + test('should update the current time marker props after time passes when the next minute is in less than a minute', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-06-01T11:00:55')); + + const { result } = renderHook(() => useCalendar({ viewMode: { value: 1, unit: 'week' } })); + const currentTimeMarkerProps = result.current.getCurrentTimeMarkerProps(); + + act(() => { + vi.advanceTimersByTime(5000); + }) + + waitFor(() => { + expect(currentTimeMarkerProps).toEqual({ + style: { + position: 'absolute', + top: '45.90277777777778%', + left: 0, + }, + currentTime: '11:01', + }); + }) + }); + + test('should render array of days', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' }, locale: 'en-US' }), + ); + + const { days } = result.current; + const weeks = result.current.groupDaysBy({ days, unit: 'week' }); + + expect(weeks).toHaveLength(5); + expect(weeks[0]).toHaveLength(7); + + expect(weeks[0]?.[0]?.date.toString()).toBe('2024-05-27'); + expect(weeks[weeks.length - 1]?.[0]?.date.toString()).toBe('2024-06-24'); + expect(weeks.find((week) => week.some((day) => day?.isToday))?.find((day) => day?.isToday)?.date.toString()).toBe('2024-06-01'); + }); + + test('should return the correct day names based on the locale', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' }, locale: 'en-US' }) + ); + + const { daysNames } = result.current; + expect(daysNames).toEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); + }); + + test('should correctly mark days as in current period', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' }, locale: 'en-US' }) + ); + + const { days } = result.current; + const weeks = result.current.groupDaysBy({ days, unit: 'week' }); + const daysInCurrentPeriod = weeks.flat().map(day => day?.isInCurrentPeriod); + + expect(daysInCurrentPeriod).toEqual([ + false, false, false, false, false, true, true, + true, true, true, true, true, true, true, + true, true, true, true, true, true, true, + true, true, true, true, true, true, true, + true, true, true, true, true, true, true + ]); + }); + + test('should navigate to a specific period correctly', () => { + const { result } = renderHook(() => useCalendar({ events, viewMode: { value: 1, unit: 'month' } })) + const specificDate = Temporal.PlainDate.from('2024-05-15') + + act(() => { + result.current.goToSpecificPeriod(specificDate) + }) + + expect(result.current.currentPeriod).toEqual(specificDate) + }) + + test('should navigate to the previous period correctly', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' }, locale: 'en-US' }) + ) + + act(() => { + result.current.goToPreviousPeriod() + }) + + const expectedPreviousMonth = Temporal.Now.plainDateISO().subtract({ + months: 1, + }) + + expect(result.current.currentPeriod).toEqual(expectedPreviousMonth) + }) + + test('should navigate to the next period correctly', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' }, locale: 'en-US' }) + ) + + act(() => { + result.current.goToNextPeriod() + }) + + const expectedNextMonth = Temporal.Now.plainDateISO().add({ months: 1 }) + + expect(result.current.currentPeriod).toEqual(expectedNextMonth) + }) + + test('should reset to the current period correctly', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' }, locale: 'en-US' }) + ) + + act(() => { + result.current.goToNextPeriod() + result.current.goToCurrentPeriod() + }) + + expect(result.current.currentPeriod).toEqual( + Temporal.Now.plainDateISO(), + ) + }) + + test('should group days by months correctly', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 2, unit: 'month' }, locale: 'en-US' }) + ); + + const { days, groupDaysBy } = result.current; + const months = groupDaysBy({ days, unit: 'month' }); + + expect(months).toHaveLength(2); + expect(months[0]?.[0]?.date.toString()).toBe('2024-06-01'); + expect(months[1]?.[0]?.date.toString()).toBe('2024-07-01'); + }); + + test('should group days by weeks correctly', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' }, locale: 'en-US' }) + ); + + const { days, groupDaysBy } = result.current; + const weeks = groupDaysBy({ days, unit: 'week' }); + expect(weeks).toHaveLength(5); + expect(weeks[0]?.[0]?.date.toString()).toBe('2024-05-27'); + expect(weeks[4]?.[6]?.date.toString()).toBe('2024-06-30'); + }); +}); diff --git a/packages/react-time/src/useCalendar/index.ts b/packages/react-time/src/useCalendar/index.ts new file mode 100644 index 0000000..0b95ae9 --- /dev/null +++ b/packages/react-time/src/useCalendar/index.ts @@ -0,0 +1 @@ +export { useCalendar } from './useCalendar'; diff --git a/packages/react-time/src/useCalendar/useCalendar.ts b/packages/react-time/src/useCalendar/useCalendar.ts new file mode 100644 index 0000000..84a236c --- /dev/null +++ b/packages/react-time/src/useCalendar/useCalendar.ts @@ -0,0 +1,84 @@ +import { useCallback, useRef, useState, useTransition } from 'react' +import { useStore } from '@tanstack/react-store' +import { Temporal } from '@js-temporal/polyfill' +import { CalendarCore, type Event } from '@tanstack/time' +import { useIsomorphicLayoutEffect } from '../utils' +import type { CalendarApi, CalendarCoreOptions } from '@tanstack/time' + +export const useCalendar = ( + options: CalendarCoreOptions, +): CalendarApi & { isPending: boolean } => { + const [calendarCore] = useState(() => new CalendarCore(options)) + const state = useStore(calendarCore.store) + const [isPending, startTransition] = useTransition() + const currentTimeInterval = useRef() + + const updateCurrentTime = useCallback(() => { + calendarCore.updateCurrentTime() + }, [calendarCore]) + + useIsomorphicLayoutEffect(() => { + if (currentTimeInterval.current) clearTimeout(currentTimeInterval.current) + + const now = Temporal.Now.plainDateTimeISO() + const msToNextMinute = (60 - now.second) * 1000 - now.millisecond + + currentTimeInterval.current = setTimeout(() => { + updateCurrentTime() + currentTimeInterval.current = setInterval(updateCurrentTime, 60000) + }, msToNextMinute) + + return () => clearTimeout(currentTimeInterval.current) + }, [calendarCore, updateCurrentTime]) + + const goToPreviousPeriod = useCallback(() => { + startTransition(() => { + calendarCore.goToPreviousPeriod() + }) + }, [calendarCore, startTransition]) + + const goToNextPeriod = useCallback(() => { + startTransition(() => { + calendarCore.goToNextPeriod() + }) + }, [calendarCore, startTransition]) + + const goToCurrentPeriod = useCallback(() => { + startTransition(() => { + calendarCore.goToCurrentPeriod() + }) + }, [calendarCore, startTransition]) + + const goToSpecificPeriod = useCallback((date) => { + startTransition(() => { + calendarCore.goToSpecificPeriod(date) + }) + }, [calendarCore, startTransition]) + + const changeViewMode = useCallback((newViewMode) => { + startTransition(() => { + calendarCore.changeViewMode(newViewMode) + }) + }, [calendarCore, startTransition]) + + const getEventProps = useCallback((id) => calendarCore.getEventProps(id), [calendarCore]) + + const getCurrentTimeMarkerProps = useCallback(() => calendarCore.getCurrentTimeMarkerProps(), [calendarCore]) + + const groupDaysBy = useCallback((props) => calendarCore.groupDaysBy(props), [calendarCore]) + + return { + ...state, + days: calendarCore.getDaysWithEvents(), + daysNames: calendarCore.getDaysNames(), + goToPreviousPeriod, + goToNextPeriod, + goToCurrentPeriod, + goToSpecificPeriod, + changeViewMode, + getEventProps, + getCurrentTimeMarkerProps, + isPending, + groupDaysBy, + } +} diff --git a/packages/react-time/src/utils/index.ts b/packages/react-time/src/utils/index.ts new file mode 100644 index 0000000..881ccca --- /dev/null +++ b/packages/react-time/src/utils/index.ts @@ -0,0 +1 @@ +export * from './useIsomorphicLayoutEffect' diff --git a/packages/react-time/src/utils/useIsomorphicLayoutEffect.ts b/packages/react-time/src/utils/useIsomorphicLayoutEffect.ts new file mode 100644 index 0000000..287b88a --- /dev/null +++ b/packages/react-time/src/utils/useIsomorphicLayoutEffect.ts @@ -0,0 +1,4 @@ +import { useEffect, useLayoutEffect } from "react"; + +export const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect \ No newline at end of file diff --git a/packages/react-time/vite.config.ts b/packages/react-time/vite.config.ts index 68b8411..36d0656 100644 --- a/packages/react-time/vite.config.ts +++ b/packages/react-time/vite.config.ts @@ -1,11 +1,9 @@ import { defineConfig, mergeConfig } from 'vitest/config' import { tanstackBuildConfig } from '@tanstack/config/build' -import react from '@vitejs/plugin-react' const config = defineConfig({ - plugins: [react()], test: { - name: 'react-time', + name: 'time', dir: './src', watch: false, environment: 'jsdom', diff --git a/packages/time/package.json b/packages/time/package.json index 081903b..44f5812 100644 --- a/packages/time/package.json +++ b/packages/time/package.json @@ -55,5 +55,12 @@ "files": [ "dist", "src" - ] + ], + "dependencies": { + "@js-temporal/polyfill": "^0.4.4", + "@tanstack/store": "^0.4.1" + }, + "devDependencies": { + "csstype": "^3.1.3" + } } diff --git a/packages/time/src/calendar/generateDateRange.ts b/packages/time/src/calendar/generateDateRange.ts new file mode 100644 index 0000000..ec73960 --- /dev/null +++ b/packages/time/src/calendar/generateDateRange.ts @@ -0,0 +1,14 @@ +import { Temporal } from '@js-temporal/polyfill' + +export const generateDateRange = ( + start: Temporal.PlainDate, + end: Temporal.PlainDate, +): Temporal.PlainDate[] => { + const dates: Temporal.PlainDate[] = [] + let current = start + while (Temporal.PlainDate.compare(current, end) <= 0) { + dates.push(current) + current = current.add({ days: 1 }) + } + return dates +} diff --git a/packages/time/src/calendar/getEventProps.ts b/packages/time/src/calendar/getEventProps.ts new file mode 100644 index 0000000..023d59b --- /dev/null +++ b/packages/time/src/calendar/getEventProps.ts @@ -0,0 +1,78 @@ +import { Temporal } from "@js-temporal/polyfill"; +import type { Properties as CSSProperties } from "csstype"; +import type { CalendarState, Event } from "./types"; + +export const getEventProps = ( + eventMap: Map, + id: Event['id'], + state: CalendarState +): { style: CSSProperties } | null => { + const event = [...eventMap.values()].flat().find((currEvent) => currEvent.id === id); + if (!event) return null; + + const eventStartDate = Temporal.PlainDateTime.from(event.startDate); + const eventEndDate = Temporal.PlainDateTime.from(event.endDate); + const isSplitEvent = Temporal.PlainDate.compare(eventStartDate.toPlainDate(), eventEndDate.toPlainDate()) !== 0; + + let percentageOfDay; + let eventHeightInMinutes; + + if (isSplitEvent) { + const isStartPart = eventStartDate.hour !== 0 || eventStartDate.minute !== 0; + if (isStartPart) { + const eventTimeInMinutes = eventStartDate.hour * 60 + eventStartDate.minute; + percentageOfDay = (eventTimeInMinutes / (24 * 60)) * 100; + eventHeightInMinutes = 24 * 60 - eventTimeInMinutes; + } else { + percentageOfDay = 0; + eventHeightInMinutes = eventEndDate.hour * 60 + eventEndDate.minute; + } + } else { + const eventTimeInMinutes = eventStartDate.hour * 60 + eventStartDate.minute; + percentageOfDay = (eventTimeInMinutes / (24 * 60)) * 100; + const endTimeInMinutes = eventEndDate.hour * 60 + eventEndDate.minute; + eventHeightInMinutes = endTimeInMinutes - eventTimeInMinutes; + } + + const eventHeight = Math.min((eventHeightInMinutes / (24 * 60)) * 100, 20); + + const overlappingEvents = [...eventMap.values()].flat().filter((e) => { + const eStartDate = Temporal.PlainDateTime.from(e.startDate); + const eEndDate = Temporal.PlainDateTime.from(e.endDate); + return ( + (e.id !== id && + Temporal.PlainDateTime.compare(eventStartDate, eStartDate) >= 0 && + Temporal.PlainDateTime.compare(eventStartDate, eEndDate) <= 0) || + (Temporal.PlainDateTime.compare(eventEndDate, eStartDate) >= 0 && + Temporal.PlainDateTime.compare(eventEndDate, eEndDate) <= 0) || + (Temporal.PlainDateTime.compare(eStartDate, eventStartDate) >= 0 && + Temporal.PlainDateTime.compare(eStartDate, eventEndDate) <= 0) || + (Temporal.PlainDateTime.compare(eEndDate, eventStartDate) >= 0 && + Temporal.PlainDateTime.compare(eEndDate, eventEndDate) <= 0) + ); + }); + + const eventIndex = overlappingEvents.findIndex((e) => e.id === id); + const totalOverlaps = overlappingEvents.length; + const sidePadding = 2; + const innerPadding = 2; + const totalInnerPadding = (totalOverlaps - 1) * innerPadding; + const availableWidth = 100 - totalInnerPadding - 2 * sidePadding; + const eventWidth = totalOverlaps > 0 ? availableWidth / totalOverlaps : 100 - 2 * sidePadding; + const eventLeft = sidePadding + eventIndex * (eventWidth + innerPadding); + + if (state.viewMode.unit === 'week' || state.viewMode.unit === 'day') { + return { + style: { + position: 'absolute', + top: `min(${percentageOfDay}%, calc(100% - 55px))`, + left: `${eventLeft}%`, + width: `${eventWidth}%`, + margin: 0, + height: `${eventHeight}%`, + }, + }; + } + + return null; +}; \ No newline at end of file diff --git a/packages/time/src/calendar/groupDaysBy.ts b/packages/time/src/calendar/groupDaysBy.ts new file mode 100644 index 0000000..8b19e98 --- /dev/null +++ b/packages/time/src/calendar/groupDaysBy.ts @@ -0,0 +1,97 @@ +import { Temporal } from "@js-temporal/polyfill"; +import type { Day, Event } from "./types"; + +interface GroupDaysByBaseProps { + days: (Day | null)[]; + weekStartsOn: number; +} + +type GroupDaysByMonthProps = GroupDaysByBaseProps & { + unit: 'month'; + fillMissingDays?: never; +}; + +type GroupDaysByWeekProps = GroupDaysByBaseProps & { + unit: 'week'; + fillMissingDays?: boolean; +}; + +export type GroupDaysByProps = GroupDaysByMonthProps | GroupDaysByWeekProps; + +export const groupDaysBy = ({ + days, + unit, + fillMissingDays = true, + weekStartsOn, +}: GroupDaysByProps): (Day | null)[][] => { + const groups: (Day | null)[][] = []; + + switch (unit) { + case 'month': { + let currentMonth: (Day | null)[] = []; + days.forEach((day) => { + if (currentMonth.length > 0 && day?.date.month !== currentMonth[0]?.date.month) { + groups.push(currentMonth); + currentMonth = []; + } + currentMonth.push(day); + }); + if (currentMonth.length > 0) { + groups.push(currentMonth); + } + break; + } + + case 'week': { + const weeks: (Day | null)[][] = []; + let currentWeek: (Day | null)[] = []; + + days.forEach((day) => { + if (currentWeek.length === 0 && day?.date.dayOfWeek !== weekStartsOn) { + if (day) { + const dayOfWeek = (day.date.dayOfWeek - weekStartsOn + 7) % 7; + for (let i = 0; i < dayOfWeek; i++) { + currentWeek.push( + fillMissingDays + ? { + date: day.date.subtract({ days: dayOfWeek - i }), + events: [], + isToday: false, + isInCurrentPeriod: false, + } + : null + ); + } + } + } + currentWeek.push(day); + if (currentWeek.length === 7) { + weeks.push(currentWeek); + currentWeek = []; + } + }); + + if (currentWeek.length > 0) { + while (currentWeek.length < 7) { + const lastDate = currentWeek[currentWeek.length - 1]?.date ?? Temporal.PlainDate.from('2024-01-01'); + currentWeek.push( + fillMissingDays + ? { + date: lastDate.add({ days: 1 }), + events: [], + isToday: false, + isInCurrentPeriod: false, + } + : null + ); + } + weeks.push(currentWeek); + } + + return weeks; + } + default: + break; + } + return groups; +}; diff --git a/packages/time/src/calendar/index.ts b/packages/time/src/calendar/index.ts new file mode 100644 index 0000000..a16de01 --- /dev/null +++ b/packages/time/src/calendar/index.ts @@ -0,0 +1,5 @@ +export * from './types' +export * from './splitMultiDayEvents' +export * from './generateDateRange' +export * from './getEventProps' +export * from './groupDaysBy' \ No newline at end of file diff --git a/packages/time/src/calendar/splitMultiDayEvents.ts b/packages/time/src/calendar/splitMultiDayEvents.ts new file mode 100644 index 0000000..0d2088d --- /dev/null +++ b/packages/time/src/calendar/splitMultiDayEvents.ts @@ -0,0 +1,23 @@ +import { Temporal } from '@js-temporal/polyfill'; +import type { Event } from './types'; + +export const splitMultiDayEvents = (event: TEvent, timeZone: Temporal.TimeZoneLike): TEvent[] => { + const startDate = event.startDate instanceof Temporal.PlainDateTime ? event.startDate.toZonedDateTime(timeZone) : event.startDate; + const endDate = event.endDate instanceof Temporal.PlainDateTime ? event.endDate.toZonedDateTime(timeZone) : event.endDate; + const events: TEvent[] = []; + + let currentDay = startDate; + while (Temporal.ZonedDateTime.compare(currentDay, endDate) < 0) { + const startOfCurrentDay = currentDay.with({ hour: 0, minute: 0, second: 0, millisecond: 0 }); + const endOfCurrentDay = currentDay.with({ hour: 23, minute: 59, second: 59, millisecond: 999 }); + + const eventStart = Temporal.PlainDateTime.compare(currentDay, startDate) === 0 ? startDate : startOfCurrentDay; + const eventEnd = Temporal.PlainDateTime.compare(endDate, endOfCurrentDay) <= 0 ? endDate : endOfCurrentDay; + + events.push({ ...event, startDate: eventStart, endDate: eventEnd }); + + currentDay = startOfCurrentDay.add({ days: 1 }); + } + + return events; +}; diff --git a/packages/time/src/calendar/types.ts b/packages/time/src/calendar/types.ts new file mode 100644 index 0000000..08ffe7e --- /dev/null +++ b/packages/time/src/calendar/types.ts @@ -0,0 +1,24 @@ +import type { Temporal } from "@js-temporal/polyfill" + +export interface Event { + id: string; + startDate: Temporal.PlainDateTime | Temporal.ZonedDateTime; + endDate: Temporal.PlainDateTime | Temporal.ZonedDateTime; + title: string; +} + +export interface CalendarStore { + currentPeriod: Temporal.PlainDate + viewMode: { + value: number + unit: 'month' | 'week' | 'day' + } + currentTime: Temporal.PlainDateTime +} + +export type Day = { + date: Temporal.PlainDate + events: TEvent[] + isToday: boolean + isInCurrentPeriod: boolean +} \ No newline at end of file diff --git a/packages/time/src/core/calendar.ts b/packages/time/src/core/calendar.ts new file mode 100644 index 0000000..ecbc94a --- /dev/null +++ b/packages/time/src/core/calendar.ts @@ -0,0 +1,397 @@ +import { Store } from '@tanstack/store' +import { Temporal } from '@js-temporal/polyfill' +import { getFirstDayOfMonth, getFirstDayOfWeek } from '../utils' +import { generateDateRange } from '../calendar/generateDateRange' +import { splitMultiDayEvents } from '../calendar/splitMultiDayEvents' +import { getEventProps } from '../calendar/getEventProps' +import { groupDaysBy } from '../calendar/groupDaysBy' +import { getDateDefaults } from '../utils/dateDefaults' +import type { Properties as CSSProperties } from 'csstype' +import type { GroupDaysByProps } from '../calendar/groupDaysBy' +import type { CalendarStore, Day, Event } from '../calendar/types' + +import './weekInfoPolyfill' + +export type { CalendarStore, Event, Day } from '../calendar/types' + +/** + * Represents the configuration for the current viewing mode of a calendar, + * specifying the scale and unit of time. + */ +export interface ViewMode { + /** The number of units for the view mode. */ + value: number + /** The unit of time that the calendar view should display (month, week, or day). */ + unit: 'month' | 'week' | 'day' +} + +/** + * Configuration options for initializing a CalendarCore instance, allowing customization + * of events, locale, time zone, and the calendar system. + * @template TEvent - Specifies the event type, extending a base Event type. + */ +export interface CalendarCoreOptions { + /** An optional array of events to be handled by the calendar. */ + events?: TEvent[] | null + /** The initial view mode configuration of the calendar. */ + viewMode: CalendarStore['viewMode'] + /** Optional locale for date formatting. Uses a BCP 47 language tag. */ + locale?: Intl.UnicodeBCP47LocaleIdentifier + /** Optional time zone specification for the calendar. */ + timeZone?: Temporal.TimeZoneLike + /** Optional calendar system to be used. */ + calendar?: Temporal.CalendarLike +} + +/** + * The API surface provided by CalendarCore, allowing interaction with the calendar's state + * and manipulation of its settings and data. + * @template TEvent - The type of events handled by the calendar. + */ +interface CalendarActions { + /** Navigates to the previous period according to the current view mode. */ + goToPreviousPeriod: () => void + /** Navigates to the next period according to the current view mode. */ + goToNextPeriod: () => void + /** Resets the view to the current period based on today's date. */ + goToCurrentPeriod: () => void + /** Navigates to a specific date. */ + goToSpecificPeriod: (date: Temporal.PlainDate) => void + /** Changes the current view mode of the calendar. */ + changeViewMode: (newViewMode: CalendarStore['viewMode']) => void + /** Retrieves styling properties for a specific event, identified by ID. */ + getEventProps: (id: Event['id']) => { style: CSSProperties } | null + /** Provides properties for the marker indicating the current time. */ + getCurrentTimeMarkerProps: () => { + style: CSSProperties + currentTime: string | undefined + } + /** Groups days by a specified unit. */ + groupDaysBy: ( + props: Omit, 'weekStartsOn'>, + ) => (Day | null)[][] +} + +interface CalendarState { + /** The currently focused date period in the calendar. */ + currentPeriod: CalendarStore['currentPeriod'] + /** The current view mode of the calendar. */ + viewMode: CalendarStore['viewMode'] + /** The current date and time according to the calendar's time zone. */ + currentTime: CalendarStore['currentTime'] + /** An array of days, each potentially containing events. */ + days: Array> + /** An array of names for the days of the week, localized to the calendar's locale. */ + daysNames: string[] +} + +export interface CalendarApi + extends CalendarActions, + CalendarState {} + +/** + * Core functionality for a calendar system, managing the state and operations of the calendar, + * such as navigating through time periods, handling events, and adjusting settings. + * @template TEvent - The type of events managed by the calendar. + */ +export class CalendarCore + implements CalendarActions +{ + store: Store + options: Required> + + constructor(options: CalendarCoreOptions) { + const defaults = getDateDefaults() + this.options = { + ...options, + locale: options.locale || defaults.locale, + timeZone: options.timeZone || defaults.timeZone, + calendar: options.calendar || defaults.calendar, + events: options.events || null, + } + + this.store = new Store({ + currentPeriod: Temporal.Now.plainDateISO().withCalendar( + this.options.calendar, + ), + viewMode: options.viewMode, + currentTime: Temporal.Now.plainDateTimeISO(this.options.timeZone), + }) + } + + private getFirstDayOfMonth() { + return getFirstDayOfMonth( + this.store.state.currentPeriod + .toString({ calendarName: 'auto' }) + .substring(0, 7), + ) + } + + private getFirstDayOfWeek() { + return getFirstDayOfWeek( + this.store.state.currentPeriod.toString(), + this.options.locale, + ) + } + + private getCalendarDays() { + const start = + this.store.state.viewMode.unit === 'month' + ? this.getFirstDayOfMonth().subtract({ + days: + (this.getFirstDayOfMonth().dayOfWeek - + (this.getFirstDayOfWeek().dayOfWeek + 1) + + 7) % + 7, + }) + : this.store.state.currentPeriod + + let end + switch (this.store.state.viewMode.unit) { + case 'month': { + const lastDayOfMonth = this.getFirstDayOfMonth() + .add({ months: this.store.state.viewMode.value }) + .subtract({ days: 1 }) + const lastDayOfMonthWeekDay = + (lastDayOfMonth.dayOfWeek - + (this.getFirstDayOfWeek().dayOfWeek + 1) + + 7) % + 7 + end = lastDayOfMonth.add({ days: 6 - lastDayOfMonthWeekDay }) + break + } + case 'week': { + end = this.getFirstDayOfWeek().add({ + days: 7 * this.store.state.viewMode.value - 1, + }) + break + } + case 'day': { + end = this.store.state.currentPeriod.add({ + days: this.store.state.viewMode.value - 1, + }) + break + } + } + + const allDays = generateDateRange(start, end) + const startMonth = this.store.state.currentPeriod.month + const endMonth = this.store.state.currentPeriod.add({ + months: this.store.state.viewMode.value - 1, + }).month + + return allDays.filter( + (day) => day.month >= startMonth && day.month <= endMonth, + ) + } + + private getEventMap() { + const map = new Map() + this.options.events?.forEach((event) => { + const eventStartDate = + event.startDate instanceof Temporal.PlainDateTime + ? event.startDate.toZonedDateTime(this.options.timeZone) + : event.startDate + const eventEndDate = + event.endDate instanceof Temporal.PlainDateTime + ? event.endDate.toZonedDateTime(this.options.timeZone) + : event.endDate + if (Temporal.ZonedDateTime.compare(eventStartDate, eventEndDate) !== 0) { + const splitEvents = splitMultiDayEvents( + event, + this.options.timeZone, + ) + splitEvents.forEach((splitEvent) => { + const splitKey = splitEvent.startDate.toString().split('T')[0] + if (splitKey) { + if (!map.has(splitKey)) map.set(splitKey, []) + map.get(splitKey)?.push(splitEvent) + } + }) + } else { + const eventKey = event.startDate.toString().split('T')[0] + if (eventKey) { + if (!map.has(eventKey)) map.set(eventKey, []) + map.get(eventKey)?.push(event) + } + } + }) + return map + } + + getDaysWithEvents() { + const calendarDays = this.getCalendarDays() + const eventMap = this.getEventMap() + return calendarDays.map((day) => { + const dayKey = day.toString() + const dailyEvents = eventMap.get(dayKey) ?? [] + const currentMonthRange = Array.from( + { length: this.store.state.viewMode.value }, + (_, i) => this.store.state.currentPeriod.add({ months: i }).month, + ) + const isInCurrentPeriod = currentMonthRange.includes(day.month) + return { + date: day, + events: dailyEvents, + isToday: + Temporal.PlainDate.compare(day, Temporal.Now.plainDateISO()) === 0, + isInCurrentPeriod, + } + }) + } + + getDaysNames() { + const baseDate = Temporal.PlainDate.from('2024-01-01') + return Array.from({ length: 7 }).map((_, i) => + baseDate + .add({ days: (i + (this.getFirstDayOfWeek().dayOfWeek + 1)) % 7 }) + .toLocaleString(this.options.locale, { weekday: 'short' }), + ) + } + + changeViewMode(newViewMode: CalendarStore['viewMode']) { + this.store.setState((prev) => ({ + ...prev, + viewMode: newViewMode, + })) + } + + goToPreviousPeriod() { + const firstDayOfMonth = this.getFirstDayOfMonth() + const firstDayOfWeek = this.getFirstDayOfWeek() + + switch (this.store.state.viewMode.unit) { + case 'month': { + const firstDayOfPrevMonth = firstDayOfMonth.subtract({ + months: this.store.state.viewMode.value, + }) + this.store.setState((prev) => ({ + ...prev, + currentPeriod: firstDayOfPrevMonth, + })) + break + } + + case 'week': { + const firstDayOfPrevWeek = firstDayOfWeek.subtract({ + weeks: this.store.state.viewMode.value, + }) + this.store.setState((prev) => ({ + ...prev, + currentPeriod: firstDayOfPrevWeek, + })) + break + } + + case 'day': { + const prevCustomStart = this.store.state.currentPeriod.subtract({ + days: this.store.state.viewMode.value, + }) + this.store.setState((prev) => ({ + ...prev, + currentPeriod: prevCustomStart, + })) + break + } + } + } + + goToNextPeriod() { + const firstDayOfMonth = this.getFirstDayOfMonth() + const firstDayOfWeek = this.getFirstDayOfWeek() + + switch (this.store.state.viewMode.unit) { + case 'month': { + const firstDayOfNextMonth = firstDayOfMonth.add({ + months: this.store.state.viewMode.value, + }) + this.store.setState((prev) => ({ + ...prev, + currentPeriod: firstDayOfNextMonth, + })) + break + } + + case 'week': { + const firstDayOfNextWeek = firstDayOfWeek.add({ + weeks: this.store.state.viewMode.value, + }) + this.store.setState((prev) => ({ + ...prev, + currentPeriod: firstDayOfNextWeek, + })) + break + } + + case 'day': { + const nextCustomStart = this.store.state.currentPeriod.add({ + days: this.store.state.viewMode.value, + }) + this.store.setState((prev) => ({ + ...prev, + currentPeriod: nextCustomStart, + })) + break + } + } + } + + goToCurrentPeriod() { + this.store.setState((prev) => ({ + ...prev, + currentPeriod: Temporal.Now.plainDateISO(), + })) + } + + goToSpecificPeriod(date: Temporal.PlainDate) { + this.store.setState((prev) => ({ + ...prev, + currentPeriod: date, + })) + } + + updateCurrentTime() { + this.store.setState((prev) => ({ + ...prev, + currentTime: Temporal.Now.plainDateTimeISO(), + })) + } + + getEventProps(id: Event['id']) { + return getEventProps(this.getEventMap(), id, this.store.state) + } + + getCurrentTimeMarkerProps(): { + style: CSSProperties + currentTime: string | undefined + } { + const { hour, minute } = this.store.state.currentTime + const currentTimeInMinutes = hour * 60 + minute + const percentageOfDay = (currentTimeInMinutes / (24 * 60)) * 100 + + return { + style: { + position: 'absolute', + top: `${percentageOfDay}%`, + left: 0, + }, + currentTime: this.store.state.currentTime + .toString() + .split('T')[1] + ?.substring(0, 5), + } + } + + groupDaysBy({ + days, + unit, + fillMissingDays = true, + }: Omit, 'weekStartsOn'>) { + return groupDaysBy({ + days, + unit, + fillMissingDays, + weekStartsOn: this.getFirstDayOfWeek().dayOfWeek, + } as GroupDaysByProps) + } +} diff --git a/packages/time/src/core/date-picker.ts b/packages/time/src/core/date-picker.ts new file mode 100644 index 0000000..a238f2c --- /dev/null +++ b/packages/time/src/core/date-picker.ts @@ -0,0 +1,95 @@ +import { Store } from '@tanstack/store' +import { Temporal } from '@js-temporal/polyfill' +import { getDateDefaults } from '../utils/dateDefaults' +import { CalendarCore } from './calendar' +import type { CalendarCoreOptions, CalendarStore } from './calendar' + +export interface DatePickerOptions extends CalendarCoreOptions { + /** + * The earliest date that can be selected. Null if no minimum constraint. + */ + minDate?: Temporal.PlainDate | null + /** + * The latest date that can be selected. Null if no maximum constraint. + */ + maxDate?: Temporal.PlainDate | null + /** + * Allows selection of multiple dates. + */ + multiple?: boolean + /** + * Allows selection of a range of dates. + */ + range?: boolean + /** + * Initial set of selected dates. + */ + selectedDates?: Temporal.PlainDate[] +} + +export interface DatePickerCoreState extends CalendarStore { + /** + * A map of selected dates, keyed by their string representation. + */ + selectedDates: Map +} + +export class DatePickerCore extends CalendarCore { + datePickerStore: Store + options: Required + + constructor(options: DatePickerOptions) { + super(options) + const defaults = getDateDefaults() + + this.options = { + ...options, + multiple: options.multiple ?? false, + range: options.range ?? false, + minDate: options.minDate ?? null, + maxDate: options.maxDate ?? null, + selectedDates: options.selectedDates ?? [], + events: options.events ?? [], + locale: options.locale ?? defaults.locale, + timeZone: options.timeZone ?? defaults.timeZone, + calendar: options.calendar ?? defaults.calendar, + } + this.datePickerStore = new Store({ + ...this.store.state, + selectedDates: new Map( + options.selectedDates?.map((date) => [date.toString(), date]) ?? [], + ), + }) + } + + getSelectedDates() { + return Array.from(this.datePickerStore.state.selectedDates.values()) + } + + selectDate(date: Temporal.PlainDate) { + const { multiple, range, minDate, maxDate } = this.options + + if (minDate && Temporal.PlainDate.compare(date, minDate) < 0) return + if (maxDate && Temporal.PlainDate.compare(date, maxDate) > 0) return + + const selectedDates = new Map(this.datePickerStore.state.selectedDates) + + if (range && selectedDates.size === 1) { + selectedDates.set(date.toString(), date) + } else if (multiple) { + if (selectedDates.has(date.toString())) { + selectedDates.delete(date.toString()) + } else { + selectedDates.set(date.toString(), date) + } + } else { + selectedDates.clear() + selectedDates.set(date.toString(), date) + } + + this.datePickerStore.setState((prev) => ({ + ...prev, + selectedDates, + })) + } +} diff --git a/packages/time/src/core/index.ts b/packages/time/src/core/index.ts new file mode 100644 index 0000000..e00be3c --- /dev/null +++ b/packages/time/src/core/index.ts @@ -0,0 +1 @@ +export * from './calendar' diff --git a/packages/time/src/core/weekInfoPolyfill.ts b/packages/time/src/core/weekInfoPolyfill.ts new file mode 100644 index 0000000..28c7351 --- /dev/null +++ b/packages/time/src/core/weekInfoPolyfill.ts @@ -0,0 +1,1864 @@ +interface WeekInfo { + firstDay: number + weekend: number[] + minimalDays: number +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +declare namespace Intl { + interface Locale { + getWeekInfo: () => WeekInfo + weekInfo?: WeekInfo + } +} + +;(function () { + if (typeof (Intl as any).Locale.prototype.getWeekInfo !== 'function') { + ;(Intl as any).Locale.prototype.getWeekInfo = function () { + const locale = this.toString().toLowerCase() + const weekInfo: Record = { + af: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ak: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + sq: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + am: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ar: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + hy: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + as: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + asa: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + az: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + bm: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + eu: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + be: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + bem: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + bez: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + bn: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + bs: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + bg: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + my: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ca: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + tzm: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + chr: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + cgg: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + zh: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + kw: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + hr: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + cs: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + da: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + nl: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ebu: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + en: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + eo: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + et: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ee: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + fo: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + fil: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + fi: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + fr: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ff: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + gl: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + lg: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ka: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + de: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + el: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + gu: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + guz: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ha: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + haw: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + he: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + hi: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + hu: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + is: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ig: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + id: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ga: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + it: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ja: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + kea: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + kab: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + kl: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + kln: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + kam: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + kn: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + kk: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + km: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ki: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + rw: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + kok: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ko: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + khq: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ses: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + lag: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + lv: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + lt: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + luo: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + luy: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + mk: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + jmc: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + kde: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + mg: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ms: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ml: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + mt: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + gv: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + mr: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + mas: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + mer: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + mfe: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + naq: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ne: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + nd: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + nb: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + nn: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + nyn: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + or: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + om: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ps: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + fa: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + pl: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + pt: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + pa: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ro: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + rm: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + rof: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ru: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + rwk: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + saq: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + sg: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + seh: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + sr: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + sn: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ii: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + si: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + sk: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + sl: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + xog: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + so: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + es: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + sw: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + sv: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + gsw: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + shi: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + dav: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ta: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + te: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + teo: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + th: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + bo: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ti: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + to: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + tr: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + uk: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + ur: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + uz: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + vi: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + vun: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + cy: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + yo: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + zu: { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'af-ZA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'am-ET': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-AE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-BH': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-DZ': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-EG': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-IQ': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-JO': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-KW': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-LB': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-LY': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-MA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'arn-CL': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-OM': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-QA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-SA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-SD': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-SY': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-TN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ar-YE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'as-IN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'az-az': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'az-Cyrl-AZ': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'az-Latn-AZ': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ba-RU': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'be-BY': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'bg-BG': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'bn-BD': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'bn-IN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'bo-CN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'br-FR': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'bs-Cyrl-BA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'bs-Latn-BA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ca-ES': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'co-FR': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'cs-CZ': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'cy-GB': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'da-DK': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'de-AT': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'de-CH': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'de-DE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'de-LI': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'de-LU': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'dsb-DE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'dv-MV': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'el-CY': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'el-GR': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-029': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-AU': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-BZ': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-CA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-cb': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-GB': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-IE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-IN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-JM': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-MT': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-MY': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-NZ': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-PH': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-SG': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-TT': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-US': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-ZA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'en-ZW': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-AR': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-BO': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-CL': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-CO': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-CR': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-DO': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-EC': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-ES': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-GT': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-HN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-MX': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-NI': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-PA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-PE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-PR': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-PY': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-SV': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-US': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-UY': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'es-VE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'et-EE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'eu-ES': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'fa-IR': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'fi-FI': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'fil-PH': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'fo-FO': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'fr-BE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'fr-CA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'fr-CH': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'fr-FR': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'fr-LU': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'fr-MC': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'fy-NL': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ga-IE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'gd-GB': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'gd-ie': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'gl-ES': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'gsw-FR': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'gu-IN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ha-Latn-NG': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'he-IL': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'hi-IN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'hr-BA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'hr-HR': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'hsb-DE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'hu-HU': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'hy-AM': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'id-ID': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ig-NG': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ii-CN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'in-ID': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'is-IS': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'it-CH': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'it-IT': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'iu-Cans-CA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'iu-Latn-CA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'iw-IL': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ja-JP': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ka-GE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'kk-KZ': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'kl-GL': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'km-KH': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'kn-IN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'kok-IN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ko-KR': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ky-KG': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'lb-LU': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'lo-LA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'lt-LT': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'lv-LV': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'mi-NZ': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'mk-MK': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ml-IN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'mn-MN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'mn-Mong-CN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'moh-CA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'mr-IN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ms-BN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ms-MY': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'mt-MT': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'nb-NO': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ne-NP': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'nl-BE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'nl-NL': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'nn-NO': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'no-no': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'nso-ZA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'oc-FR': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'or-IN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'pa-IN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'pl-PL': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'prs-AF': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ps-AF': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'pt-BR': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'pt-PT': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'qut-GT': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'quz-BO': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'quz-EC': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'quz-PE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'rm-CH': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ro-mo': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ro-RO': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ru-mo': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ru-RU': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'rw-RW': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sah-RU': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sa-IN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'se-FI': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'se-NO': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'se-SE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'si-LK': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sk-SK': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sl-SI': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sma-NO': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sma-SE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'smj-NO': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'smj-SE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'smn-FI': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sms-FI': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sq-AL': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sr-BA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sr-CS': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sr-Cyrl-BA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sr-Cyrl-CS': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sr-Cyrl-ME': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sr-Cyrl-RS': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sr-Latn-BA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sr-Latn-CS': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sr-Latn-ME': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sr-Latn-RS': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sr-ME': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sr-RS': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sr-sp': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sv-FI': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sv-SE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'sw-KE': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'syr-SY': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ta-IN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'te-IN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'tg-Cyrl-TJ': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'th-TH': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'tk-TM': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'tlh-QS': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'tn-ZA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'tr-TR': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'tt-RU': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'tzm-Latn-DZ': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ug-CN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'uk-UA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'ur-PK': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'uz-Cyrl-UZ': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'uz-Latn-UZ': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'uz-uz': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'vi-VN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'wo-SN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'xh-ZA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'yo-NG': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'zh-CN': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'zh-HK': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'zh-MO': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'zh-SG': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'zh-TW': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + 'zu-ZA': { + firstDay: 1, + weekend: [6, 7], + minimalDays: 4, + }, + } + + const match = + weekInfo[locale] || + weekInfo[locale.split('-')[0]] || + weekInfo['default'] + + return { + firstDay: match?.firstDay, + weekend: match?.weekend, + minimalDays: match?.minimalDays, + } + } + } +})() diff --git a/packages/time/src/index.ts b/packages/time/src/index.ts index 12981a2..0535016 100644 --- a/packages/time/src/index.ts +++ b/packages/time/src/index.ts @@ -1,4 +1,6 @@ /** * TanStack Time */ -export * from './utils/parse'; \ No newline at end of file +export * from './utils'; +export * from './core'; + diff --git a/packages/time/src/tests/calendar-core.test.ts b/packages/time/src/tests/calendar-core.test.ts new file mode 100644 index 0000000..22f6697 --- /dev/null +++ b/packages/time/src/tests/calendar-core.test.ts @@ -0,0 +1,150 @@ +import { Temporal } from '@js-temporal/polyfill'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { CalendarCore } from '../core/calendar'; +import type { CalendarCoreOptions, Event } from '../core/calendar'; + +describe('CalendarCore', () => { + let options: CalendarCoreOptions; + let calendarCore: CalendarCore; + const mockDate = Temporal.PlainDate.from('2023-06-15'); + const mockDateTime = Temporal.PlainDateTime.from('2023-06-15T10:00'); + const mockTimeZone = 'America/New_York'; + + beforeEach(() => { + options = { + viewMode: { value: 1, unit: 'month' }, + events: [ + { + id: '1', + startDate: Temporal.PlainDateTime.from('2023-06-10T09:00'), + endDate: Temporal.PlainDateTime.from('2023-06-10T10:00'), + title: 'Event 1', + }, + { + id: '2', + startDate: Temporal.PlainDateTime.from('2023-06-12T11:00'), + endDate: Temporal.PlainDateTime.from('2023-06-12T12:00'), + title: 'Event 2', + }, + ], + timeZone: mockTimeZone, + }; + calendarCore = new CalendarCore(options); + vi.spyOn(Temporal.Now, 'plainDateISO').mockReturnValue(mockDate); + vi.spyOn(Temporal.Now, 'plainDateTimeISO').mockReturnValue(mockDateTime); + }); + + test('should initialize with the correct current period', () => { + const today = Temporal.Now.plainDateISO(); + expect(calendarCore.store.state.currentPeriod).toEqual(today); + }); + + test('should get the correct days with events for the month', () => { + const daysWithEvents = calendarCore.getDaysWithEvents(); + expect(daysWithEvents.length).toBeGreaterThan(0); + }); + + test('should correctly map events to days', () => { + const daysWithEvents = calendarCore.getDaysWithEvents(); + const dayWithEvent1 = daysWithEvents.find((day) => day.date.equals(Temporal.PlainDate.from('2023-06-10'))); + const dayWithEvent2 = daysWithEvents.find((day) => day.date.equals(Temporal.PlainDate.from('2023-06-12'))); + expect(dayWithEvent1?.events).toHaveLength(1); + expect(dayWithEvent1?.events[0]?.id).toBe('1'); + expect(dayWithEvent2?.events).toHaveLength(1); + expect(dayWithEvent2?.events[0]?.id).toBe('2'); + }); + + test('should change view mode correctly', () => { + calendarCore.changeViewMode({ value: 2, unit: 'week' }); + expect(calendarCore.store.state.viewMode.value).toBe(2); + expect(calendarCore.store.state.viewMode.unit).toBe('week'); + }); + + test('should go to previous period correctly', () => { + const initialPeriod = calendarCore.store.state.currentPeriod; + calendarCore.goToPreviousPeriod(); + const expectedPreviousMonth = initialPeriod.subtract({ months: 1 }); + expect(calendarCore.store.state.currentPeriod).toEqual(expectedPreviousMonth); + }); + + test('should go to next period correctly', () => { + const initialPeriod = calendarCore.store.state.currentPeriod; + calendarCore.goToNextPeriod(); + const expectedNextMonth = initialPeriod.add({ months: 1 }); + expect(calendarCore.store.state.currentPeriod).toEqual(expectedNextMonth); + }); + + test('should go to current period correctly', () => { + calendarCore.goToNextPeriod(); + calendarCore.goToCurrentPeriod(); + const today = Temporal.Now.plainDateISO(); + expect(calendarCore.store.state.currentPeriod).toEqual(today); + }); + + test('should go to specific period correctly', () => { + const specificDate = Temporal.PlainDate.from('2023-07-01'); + calendarCore.goToSpecificPeriod(specificDate); + expect(calendarCore.store.state.currentPeriod).toEqual(specificDate); + }); + + test('should update current time correctly', () => { + const initialTime = calendarCore.store.state.currentTime; + const newMockDateTime = initialTime.add({ minutes: 1 }); + vi.spyOn(Temporal.Now, 'plainDateTimeISO').mockReturnValue(newMockDateTime); + + calendarCore.updateCurrentTime(); + const updatedTime = calendarCore.store.state.currentTime; + expect(updatedTime).toEqual(newMockDateTime); + }); + + test('should return the correct props for the current time marker', () => { + vi.spyOn(Temporal.Now, 'plainDateTimeISO').mockReturnValue(Temporal.PlainDateTime.from('2024-06-01T11:00:00')); + + const coreOptions: CalendarCoreOptions = { + viewMode: { value: 1, unit: 'week' }, + events: [], + timeZone: mockTimeZone, + }; + calendarCore = new CalendarCore(coreOptions); + + const currentTimeMarkerProps = calendarCore.getCurrentTimeMarkerProps(); + + expect(currentTimeMarkerProps).toEqual({ + style: { + position: 'absolute', + top: '45.83333333333333%', + left: 0, + }, + currentTime: '11:00', + }); + }); + + test('should update the current time', () => { + const initialTime = calendarCore.store.state.currentTime; + const newMockDateTime = initialTime.add({ minutes: 1 }); + vi.spyOn(Temporal.Now, 'plainDateTimeISO').mockReturnValue(newMockDateTime); + + calendarCore.updateCurrentTime(); + expect(calendarCore.store.state.currentTime).toEqual(newMockDateTime); + }); + + test('should group days correctly', () => { + const daysWithEvents = calendarCore.getDaysWithEvents(); + const groupedDays = calendarCore.groupDaysBy({ days: daysWithEvents, unit: 'month' }); + expect(groupedDays.length).toBeGreaterThan(0); + }); + + test('should initialize with the correct time zone', () => { + expect(calendarCore.options.timeZone).toBe(mockTimeZone); + }); + + test('should respect custom calendar', () => { + const customCalendar = 'islamic-civil'; + options.calendar = customCalendar; + calendarCore = new CalendarCore(options); + + const today = Temporal.Now.plainDateISO(customCalendar); + expect(calendarCore.store.state.currentPeriod.calendarId).toBe(customCalendar); + expect(calendarCore.store.state.currentPeriod).toEqual(today); + }); +}); diff --git a/packages/time/src/tests/date-picker-core.test.ts b/packages/time/src/tests/date-picker-core.test.ts new file mode 100644 index 0000000..372672d --- /dev/null +++ b/packages/time/src/tests/date-picker-core.test.ts @@ -0,0 +1,82 @@ +import { Temporal } from '@js-temporal/polyfill'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { DatePicker } from '../core/date-picker'; +import type { DatePickerOptions } from '../core/date-picker'; + +describe('DatePicker', () => { + let options: DatePickerOptions; + let datePicker: DatePicker; + const mockDate = Temporal.PlainDate.from('2023-06-15'); + const mockDateTime = Temporal.PlainDateTime.from('2023-06-15T10:00'); + + beforeEach(() => { + options = { + weekStartsOn: 1, + viewMode: { value: 1, unit: 'month' }, + selectedDates: [Temporal.PlainDate.from('2023-06-10')], + multiple: true, + }; + datePicker = new DatePicker(options); + vi.spyOn(Temporal.Now, 'plainDateISO').mockReturnValue(mockDate); + vi.spyOn(Temporal.Now, 'plainDateTimeISO').mockReturnValue(mockDateTime); + }); + + test('should initialize with the correct selected dates', () => { + const selectedDates = datePicker.getSelectedDates(); + expect(selectedDates).toHaveLength(1); + expect(selectedDates[0]?.toString()).toBe('2023-06-10'); + }); + + test('should select a date correctly in single selection mode', () => { + datePicker = new DatePicker({ + ...options, + multiple: false, + }); + datePicker.selectDate(Temporal.PlainDate.from('2023-06-15')); + const selectedDates = datePicker.getSelectedDates(); + expect(selectedDates).toHaveLength(1); + expect(selectedDates[0]?.toString()).toBe('2023-06-15'); + }); + + test('should select multiple dates correctly', () => { + datePicker.selectDate(Temporal.PlainDate.from('2023-06-15')); + datePicker.selectDate(Temporal.PlainDate.from('2023-06-20')); + const selectedDates = datePicker.getSelectedDates(); + expect(selectedDates).toHaveLength(3); + expect(selectedDates.map(date => date.toString())).toContain('2023-06-15'); + expect(selectedDates.map(date => date.toString())).toContain('2023-06-20'); + }); + + test('should deselect a date correctly in multiple selection mode', () => { + datePicker.selectDate(Temporal.PlainDate.from('2023-06-10')); + const selectedDates = datePicker.getSelectedDates(); + expect(selectedDates).toHaveLength(0); + }); + + test('should select a date range correctly', () => { + datePicker = new DatePicker({ + ...options, + multiple: false, + range: true, + selectedDates: [Temporal.PlainDate.from('2023-06-10')], + }); + datePicker.selectDate(Temporal.PlainDate.from('2023-06-15')); + const selectedDates = datePicker.getSelectedDates(); + expect(selectedDates).toHaveLength(2); + expect(selectedDates.map(date => date.toString())).toContain('2023-06-10'); + expect(selectedDates.map(date => date.toString())).toContain('2023-06-15'); + }); + + test('should not select a date outside the min and max range', () => { + datePicker = new DatePicker({ + ...options, + minDate: Temporal.PlainDate.from('2023-06-05'), + maxDate: Temporal.PlainDate.from('2023-06-20'), + }); + datePicker.selectDate(Temporal.PlainDate.from('2023-06-01')); + datePicker.selectDate(Temporal.PlainDate.from('2023-06-25')); + const selectedDates = datePicker.getSelectedDates(); + expect(selectedDates).toHaveLength(1); + expect(selectedDates[0]?.toString()).toBe('2023-06-10'); + }); +}); diff --git a/packages/time/src/tests/isValidDate.test.ts b/packages/time/src/tests/isValidDate.test.ts index 57a3556..01afdc6 100644 --- a/packages/time/src/tests/isValidDate.test.ts +++ b/packages/time/src/tests/isValidDate.test.ts @@ -4,7 +4,7 @@ import {isValidDate} from '../utils/isValidDate'; describe('isValidDate', () => { test('should return true for a valid date', () => { expect(isValidDate(new Date())).toBe(true); - }); + }) test('should return false for an invalid date', () => { expect(isValidDate(new Date("invalid"))).toBe(false); @@ -13,4 +13,4 @@ describe('isValidDate', () => { test("should return false for null", () => { expect(isValidDate(null)).toBe(false); }); -}); \ No newline at end of file +}); diff --git a/packages/time/src/utils/dateDefaults.ts b/packages/time/src/utils/dateDefaults.ts index 0dd3f98..0ac14ae 100644 --- a/packages/time/src/utils/dateDefaults.ts +++ b/packages/time/src/utils/dateDefaults.ts @@ -1,7 +1,9 @@ +import type { Temporal } from "@js-temporal/polyfill"; + export interface IDateDefaults { - calendar: string; - locale: string; - timeZone: string; + calendar: Temporal.CalendarLike; + locale: Intl.UnicodeBCP47LocaleIdentifier; + timeZone: Temporal.TimeZoneLike; } const { diff --git a/packages/time/src/utils/getFirstDayOfMonth.ts b/packages/time/src/utils/getFirstDayOfMonth.ts new file mode 100644 index 0000000..845b49b --- /dev/null +++ b/packages/time/src/utils/getFirstDayOfMonth.ts @@ -0,0 +1,4 @@ +import { Temporal } from '@js-temporal/polyfill' + +export const getFirstDayOfMonth = (currMonth: string) => + Temporal.PlainDate.from(`${currMonth}-01`) diff --git a/packages/time/src/utils/getFirstDayOfWeek.ts b/packages/time/src/utils/getFirstDayOfWeek.ts new file mode 100644 index 0000000..65f71e3 --- /dev/null +++ b/packages/time/src/utils/getFirstDayOfWeek.ts @@ -0,0 +1,8 @@ +import { Temporal } from '@js-temporal/polyfill' + +export const getFirstDayOfWeek = (currWeek: string, locale: Intl.UnicodeBCP47LocaleIdentifier | Intl.Locale = 'en-US') => { + const date = Temporal.PlainDate.from(currWeek); + const loc = new Intl.Locale(locale); + const { firstDay } = loc.weekInfo || loc.getWeekInfo(); + return date.subtract({ days: (date.dayOfWeek - firstDay + 7) % 7 }); +} diff --git a/packages/time/src/utils/index.ts b/packages/time/src/utils/index.ts new file mode 100644 index 0000000..293009a --- /dev/null +++ b/packages/time/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './parse'; +export * from './getFirstDayOfMonth'; +export * from './getFirstDayOfWeek'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37c8fc7..626d76f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,12 @@ importers: packages/react-time: dependencies: + '@js-temporal/polyfill': + specifier: ^0.4.4 + version: 0.4.4 + '@tanstack/react-store': + specifier: ^0.4.1 + version: 0.4.1(react-dom@18.2.0)(react@18.2.0) '@tanstack/time': specifier: workspace:* version: link:../time @@ -159,6 +165,9 @@ importers: react-dom: specifier: ^17.0.0 || ^18.0.0 version: 18.2.0(react@18.2.0) + typesafe-actions: + specifier: ^5.1.0 + version: 5.1.0 use-sync-external-store: specifier: ^1.2.0 version: 1.2.0(react@18.2.0) @@ -183,7 +192,18 @@ importers: specifier: ^2.10.1 version: 2.10.1(@testing-library/jest-dom@6.4.2)(solid-js@1.7.8)(vite@5.2.6) - packages/time: {} + packages/time: + dependencies: + '@js-temporal/polyfill': + specifier: ^0.4.4 + version: 0.4.4 + '@tanstack/store': + specifier: ^0.4.1 + version: 0.4.1 + devDependencies: + csstype: + specifier: ^3.1.3 + version: 3.1.3 packages/vue-time: dependencies: @@ -541,7 +561,7 @@ packages: dependencies: '@ampproject/remapping': 2.3.0 '@babel/code-frame': 7.24.2 - '@babel/generator': 7.23.6 + '@babel/generator': 7.24.1 '@babel/helper-compilation-targets': 7.23.6 '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.0) '@babel/helpers': 7.24.1 @@ -2619,6 +2639,14 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true + /@js-temporal/polyfill@0.4.4: + resolution: {integrity: sha512-2X6bvghJ/JAoZO52lbgyAPFj8uCflhTo2g7nkFzEQdXd/D8rEeD4HtmTEpmtGCva260fcd66YNXBOYdnmHqSOg==} + engines: {node: '>=12'} + dependencies: + jsbi: 4.3.0 + tslib: 2.6.2 + dev: false + /@leichtgewicht/ip-codec@2.0.5: resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} dev: true @@ -3288,6 +3316,22 @@ packages: - vite dev: true + /@tanstack/react-store@0.4.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-cyriofh2I6dPOPJf2W0K+ON5q08ezevLTUhC1txiUnrJ9XFFFPr0X8CmGnXzucI2c0t0V6wYZc0GCz4zOAeptg==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + dependencies: + '@tanstack/store': 0.4.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false + + /@tanstack/store@0.4.1: + resolution: {integrity: sha512-NvW3MomYSTzQK61AWdtWNIhWgszXFZDRgCNlvSDw/DBaoLqJIlZ0/gKLsditA8un/BGU1NR06+j0a/UNLgXA+Q==} + dev: false + /@testing-library/dom@9.3.4: resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} engines: {node: '>=14'} @@ -5113,7 +5157,7 @@ packages: dom-serializer: 2.0.0 domhandler: 5.0.3 htmlparser2: 8.0.2 - postcss: 8.4.35 + postcss: 8.4.38 postcss-media-query-parser: 0.2.3 dev: true @@ -5151,12 +5195,12 @@ packages: webpack: optional: true dependencies: - icss-utils: 5.1.0(postcss@8.4.35) - postcss: 8.4.35 - postcss-modules-extract-imports: 3.0.0(postcss@8.4.35) - postcss-modules-local-by-default: 4.0.4(postcss@8.4.35) - postcss-modules-scope: 3.1.1(postcss@8.4.35) - postcss-modules-values: 4.0.0(postcss@8.4.35) + icss-utils: 5.1.0(postcss@8.4.38) + postcss: 8.4.38 + postcss-modules-extract-imports: 3.0.0(postcss@8.4.38) + postcss-modules-local-by-default: 4.0.4(postcss@8.4.38) + postcss-modules-scope: 3.1.1(postcss@8.4.38) + postcss-modules-values: 4.0.0(postcss@8.4.38) postcss-value-parser: 4.2.0 semver: 7.6.0 webpack: 5.90.3(esbuild@0.20.2) @@ -7006,13 +7050,13 @@ packages: safer-buffer: 2.1.2 dev: true - /icss-utils@5.1.0(postcss@8.4.35): + /icss-utils@5.1.0(postcss@8.4.38): resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: postcss: ^8.1.0 dependencies: - postcss: 8.4.35 + postcss: 8.4.38 dev: true /identity-function@1.0.0: @@ -7495,7 +7539,7 @@ packages: resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} engines: {node: '>=8'} dependencies: - '@babel/core': 7.24.0 + '@babel/core': 7.24.3 '@babel/parser': 7.24.1 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 @@ -7641,6 +7685,10 @@ packages: argparse: 2.0.1 dev: true + /jsbi@4.3.0: + resolution: {integrity: sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==} + dev: false + /jsdom@24.0.0: resolution: {integrity: sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==} engines: {node: '>=18'} @@ -7891,8 +7939,6 @@ packages: peerDependenciesMeta: webpack: optional: true - webpack-sources: - optional: true dependencies: webpack: 5.90.3(esbuild@0.20.2) webpack-sources: 3.2.3 @@ -9113,45 +9159,45 @@ packages: resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} dev: true - /postcss-modules-extract-imports@3.0.0(postcss@8.4.35): + /postcss-modules-extract-imports@3.0.0(postcss@8.4.38): resolution: {integrity: sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: postcss: ^8.1.0 dependencies: - postcss: 8.4.35 + postcss: 8.4.38 dev: true - /postcss-modules-local-by-default@4.0.4(postcss@8.4.35): + /postcss-modules-local-by-default@4.0.4(postcss@8.4.38): resolution: {integrity: sha512-L4QzMnOdVwRm1Qb8m4x8jsZzKAaPAgrUF1r/hjDR2Xj7R+8Zsf97jAlSQzWtKx5YNiNGN8QxmPFIc/sh+RQl+Q==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: postcss: ^8.1.0 dependencies: - icss-utils: 5.1.0(postcss@8.4.35) - postcss: 8.4.35 + icss-utils: 5.1.0(postcss@8.4.38) + postcss: 8.4.38 postcss-selector-parser: 6.0.16 postcss-value-parser: 4.2.0 dev: true - /postcss-modules-scope@3.1.1(postcss@8.4.35): + /postcss-modules-scope@3.1.1(postcss@8.4.38): resolution: {integrity: sha512-uZgqzdTleelWjzJY+Fhti6F3C9iF1JR/dODLs/JDefozYcKTBCdD8BIl6nNPbTbcLnGrk56hzwZC2DaGNvYjzA==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: postcss: ^8.1.0 dependencies: - postcss: 8.4.35 + postcss: 8.4.38 postcss-selector-parser: 6.0.16 dev: true - /postcss-modules-values@4.0.0(postcss@8.4.35): + /postcss-modules-values@4.0.0(postcss@8.4.38): resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} engines: {node: ^10 || ^12 || >= 14} peerDependencies: postcss: ^8.1.0 dependencies: - icss-utils: 5.1.0(postcss@8.4.35) - postcss: 8.4.35 + icss-utils: 5.1.0(postcss@8.4.38) + postcss: 8.4.38 dev: true /postcss-selector-parser@6.0.16: @@ -9458,7 +9504,7 @@ packages: /regenerator-transform@0.15.2: resolution: {integrity: sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==} dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.24.1 dev: true /regex-parser@2.3.0: @@ -9545,7 +9591,7 @@ packages: adjust-sourcemap-loader: 4.0.0 convert-source-map: 1.9.0 loader-utils: 2.0.4 - postcss: 8.4.35 + postcss: 8.4.38 source-map: 0.6.1 dev: true @@ -10741,7 +10787,6 @@ packages: /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} - dev: true /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -10826,6 +10871,11 @@ packages: resolution: {integrity: sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==} dev: true + /typesafe-actions@5.1.0: + resolution: {integrity: sha512-bna6Yi1pRznoo6Bz1cE6btB/Yy8Xywytyfrzu/wc+NFW3ZF0I+2iCGImhBsoYYCOWuICtRO4yHcnDlzgo1AdNg==} + engines: {node: '>= 4'} + dev: false + /typescript@4.9.3: resolution: {integrity: sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==} engines: {node: '>=4.2.0'}