diff --git a/.turbo/daemon/8560fb4b2dbac6ca-turbo.log.2024-07-18 b/.turbo/daemon/8560fb4b2dbac6ca-turbo.log.2024-07-18 new file mode 100644 index 0000000..e69de29 diff --git a/docs/framework/react/reference/useCalendar.md b/docs/framework/react/reference/useCalendar.md new file mode 100644 index 0000000..42ad7fa --- /dev/null +++ b/docs/framework/react/reference/useCalendar.md @@ -0,0 +1,223 @@ +--- +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 + +- `events?: TEvent[] | null` +An optional array of events to be handled by the calendar. +- `viewMode: CalendarStore['viewMode']` +The initial view mode configuration of the calendar. +- `locale?: Intl.UnicodeBCP47LocaleIdentifier` +Optional locale for date formatting. Uses a BCP 47 language tag. +- `timeZone?: Temporal.TimeZoneLike` +Optional time zone specification for the calendar. +- `calendar?: Temporal.CalendarLike` +Optional calendar system to be used. + + +#### 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: () => void` +This function navigates to the previous period. +`goToNextPeriod: () => void` +This function navigates to the next period. +- `goToCurrentPeriod: () => void` +This function navigates to the current period. +- `goToSpecificPeriod: (date: Temporal.PlainDate) => void` +This function navigates to a specific period based on the provided date. +- `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: CalendarStore['viewMode']` +This value represents the current view mode of the calendar. +- `changeViewMode: (newViewMode: CalendarStore['viewMode']) => 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. +- `getCurrentTimeMarkerProps: () => { style: CSSProperties, currentTime: string | undefined }` +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: (props: Omit, 'weekStartsOn'>) => (Day | null)[][]` +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({ + viewMode: { value: 1, unit: 'month' }, + events, + locale: 'en-US', + timeZone: 'America/New_York', + calendar: 'gregory', + }); + + 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..8d758c5 --- /dev/null +++ b/docs/reference/calendar-core.md @@ -0,0 +1,118 @@ +--- +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. +- `timeZone?: Temporal.TimeZoneLike` +Optional time zone specification for the calendar. +- `calendar?: Temporal.CalendarLike` +Optional calendar system to be used. + + +#### 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: CalendarStore['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({ + viewMode: { value: 1, unit: 'month' }, + events, + locale: 'en-US', + timeZone: 'America/New_York', + calendar: 'gregory', +}); + +// 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/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..006e5d4 --- /dev/null +++ b/packages/react-time/src/tests/useCalendar.test.tsx @@ -0,0 +1,187 @@ +import { Temporal } from '@js-temporal/polyfill'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { CalendarCore } from '@tanstack/time'; +import { useStore } from '@tanstack/react-store'; +import { useCalendar } from '../useCalendar'; +import type { Mock } from 'vitest'; +import type { Event } from '@tanstack/time'; + +vi.mock('@tanstack/time', () => { + return { + CalendarCore: vi.fn(), + }; +}); + +vi.mock('@tanstack/react-store', () => { + return { + useStore: vi.fn(), + }; +}); + +describe('useCalendar', () => { + const events: Event[] = [ + { + id: '1', + start: '2024-06-01T10:00:00', + end: '2024-06-01T12:00:00', + title: 'Event 1', + }, + { + id: '2', + start: '2024-06-02T14:00:00', + end: '2024-06-02T16:00:00', + title: 'Event 2', + }, + ]; + + const mockDate = Temporal.PlainDate.from('2024-06-15'); + const mockDateTime = Temporal.PlainDateTime.from('2024-06-15T10:00'); + const mockTimeZone = 'America/New_York'; + + let mockStore: any; + let calendarCoreInstance: any; + + beforeEach(() => { + vi.spyOn(Temporal.Now, 'plainDateISO').mockReturnValue(mockDate); + vi.spyOn(Temporal.Now, 'plainDateTimeISO').mockReturnValue(mockDateTime); + vi.spyOn(Temporal.Now, 'zonedDateTime').mockReturnValue(Temporal.Now.zonedDateTime('gregory', mockTimeZone)); + vi.spyOn(Temporal.Now, 'zonedDateTimeISO').mockReturnValue(Temporal.Now.zonedDateTimeISO()); + + mockStore = { + subscribe: vi.fn(), + state: { + currentPeriod: mockDate, + activeDate: mockDate, + viewMode: { value: 1, unit: 'month' }, + }, + }; + + calendarCoreInstance = { + getDaysWithEvents: vi.fn().mockReturnValue([]), + getDaysNames: vi.fn().mockReturnValue([]), + goToPreviousPeriod: vi.fn(), + goToNextPeriod: vi.fn(), + goToCurrentPeriod: vi.fn(), + goToSpecificPeriod: vi.fn(), + changeViewMode: vi.fn(), + getEventProps: vi.fn().mockReturnValue({}), + groupDaysBy: vi.fn().mockReturnValue([]), + store: mockStore, + }; + + (CalendarCore as Mock).mockImplementation(() => calendarCoreInstance); + + (useStore as Mock).mockImplementation((store) => store.state); + }); + + test('should initialize CalendarCore with provided options', () => { + renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' }, timeZone: mockTimeZone }), + ); + + expect(CalendarCore).toHaveBeenCalledWith({ + events, + viewMode: { value: 1, unit: 'month' }, + timeZone: mockTimeZone, + }); + }); + + test('should call goToPreviousPeriod on CalendarCore instance', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' } }), + ); + + act(() => { + result.current.goToPreviousPeriod(); + }); + + expect(calendarCoreInstance.goToPreviousPeriod).toHaveBeenCalled(); + }); + + test('should call goToNextPeriod on CalendarCore instance', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' } }), + ); + + act(() => { + result.current.goToNextPeriod(); + }); + + expect(calendarCoreInstance.goToNextPeriod).toHaveBeenCalled(); + }); + + test('should call changeViewMode on CalendarCore instance', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' } }), + ); + + act(() => { + result.current.changeViewMode({ value: 1, unit: 'week' }); + }); + + expect(calendarCoreInstance.changeViewMode).toHaveBeenCalledWith({ value: 1, unit: 'week' }); + }); + + test('should call goToSpecificPeriod on CalendarCore instance', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' } }), + ); + + const specificDate = '2024-06-01'; + act(() => { + result.current.goToSpecificPeriod(specificDate); + }); + + expect(calendarCoreInstance.goToSpecificPeriod).toHaveBeenCalledWith(specificDate); + }); + + test('should call goToCurrentPeriod on CalendarCore instance', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' } }), + ); + + act(() => { + result.current.goToNextPeriod(); + result.current.goToCurrentPeriod(); + }); + + expect(calendarCoreInstance.goToCurrentPeriod).toHaveBeenCalled(); + }); + + test('should call getEventProps on CalendarCore instance', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'week' } }), + ); + + act(() => { + result.current.getEventProps('1'); + }); + + expect(calendarCoreInstance.getEventProps).toHaveBeenCalledWith('1'); + }); + + test('should call getDaysNames on CalendarCore instance', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' }, locale: 'en-US' }), + ); + + act(() => { + result.current.getDaysNames('short'); + }); + + expect(calendarCoreInstance.getDaysNames).toHaveBeenCalledWith('short'); + }); + + test('should call groupDaysBy on CalendarCore instance', () => { + const { result } = renderHook(() => + useCalendar({ events, viewMode: { value: 1, unit: 'month' } }), + ); + + act(() => { + result.current.groupDaysBy({ days: [], unit: 'month' }); + }); + + expect(calendarCoreInstance.groupDaysBy).toHaveBeenCalledWith({ days: [], unit: 'month' }); + }); +}); 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..23441b0 --- /dev/null +++ b/packages/react-time/src/useCalendar/useCalendar.ts @@ -0,0 +1,64 @@ +import { useCallback, useState, useTransition } from 'react' +import { useStore } from '@tanstack/react-store' +import { CalendarCore, type Event } from '@tanstack/time' +import type { CalendarApi, CalendarCoreOptions, Resource } from '@tanstack/time' + +export const useCalendar = = Event>( + options: CalendarCoreOptions, +): CalendarApi & { isPending: boolean } => { + const [calendarCore] = useState(() => new CalendarCore(options)) + const state = useStore(calendarCore.store) + const [isPending, startTransition] = useTransition() + + 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 groupDaysBy = useCallback((props) => calendarCore.groupDaysBy(props), [calendarCore]) + + const getDaysNames = useCallback((props) => calendarCore.getDaysNames(props), [calendarCore]) + + return { + activeDate: state.activeDate.toString(), + currentPeriod: state.currentPeriod.toString(), + viewMode: state.viewMode, + days: calendarCore.getDaysWithEvents(), + getDaysNames, + goToPreviousPeriod, + goToNextPeriod, + goToCurrentPeriod, + goToSpecificPeriod, + changeViewMode, + getEventProps, + 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..285e7e4 100644 --- a/packages/time/package.json +++ b/packages/time/package.json @@ -55,5 +55,13 @@ "files": [ "dist", "src" - ] + ], + "dependencies": { + "@bart-krakowski/get-week-info-polyfill": "^1.0.6", + "@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..7aeaaca --- /dev/null +++ b/packages/time/src/calendar/generateDateRange.ts @@ -0,0 +1,20 @@ +import { Temporal } from '@js-temporal/polyfill' +import { validateDate } from '../utils/validateDate' + +export const generateDateRange = ( + start: string, + end: string, +): Temporal.PlainDate[] => { + validateDate({ date: start }) + validateDate({ date: end }) + + const startDate = Temporal.PlainDate.from(start) + const endDate = Temporal.PlainDate.from(end) + const dates: Temporal.PlainDate[] = [] + let current = startDate + while (Temporal.PlainDate.compare(current, endDate) <= 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..0aba95b --- /dev/null +++ b/packages/time/src/calendar/getEventProps.ts @@ -0,0 +1,46 @@ +import { Temporal } from '@js-temporal/polyfill' +import type { CalendarStore, Event } from './types' + +export const getEventProps = ( + eventMap: Map, + id: Event['id'], + state: CalendarStore, +) => { + const event = [...eventMap.values()] + .flat() + .find((currEvent) => currEvent.id === id) + if (!event) return null + + const eventStartDate = Temporal.ZonedDateTime.from(event.start) + const eventEndDate = Temporal.ZonedDateTime.from(event.end) + const isSplitEvent = + Temporal.PlainDate.compare( + eventStartDate.toPlainDate(), + eventEndDate.toPlainDate(), + ) !== 0 + + const overlappingEvents = [...eventMap.values()].flat().filter((e) => { + const eStartDate = Temporal.ZonedDateTime.from(e.start) + const eEndDate = Temporal.ZonedDateTime.from(e.end) + return ( + e.id !== id && + ((Temporal.ZonedDateTime.compare(eventStartDate, eStartDate) >= 0 && + Temporal.ZonedDateTime.compare(eventStartDate, eEndDate) <= 0) || + (Temporal.ZonedDateTime.compare(eventEndDate, eStartDate) >= 0 && + Temporal.ZonedDateTime.compare(eventEndDate, eEndDate) <= 0) || + (Temporal.ZonedDateTime.compare(eStartDate, eventStartDate) >= 0 && + Temporal.ZonedDateTime.compare(eStartDate, eventEndDate) <= 0) || + (Temporal.ZonedDateTime.compare(eEndDate, eventStartDate) >= 0 && + Temporal.ZonedDateTime.compare(eEndDate, eventEndDate) <= 0)) + ) + }) + + if (state.viewMode.unit === 'week' || state.viewMode.unit === 'day') { + return { + isSplitEvent, + overlappingEvents, + } + } + + return null +} diff --git a/packages/time/src/calendar/groupDaysBy.ts b/packages/time/src/calendar/groupDaysBy.ts new file mode 100644 index 0000000..b739c34 --- /dev/null +++ b/packages/time/src/calendar/groupDaysBy.ts @@ -0,0 +1,186 @@ +import { Temporal } from '@js-temporal/polyfill'; +import type { Day, Event, Resource } from './types'; + +interface GroupDaysByBaseProps< + TResource extends Resource, + TEvent extends Event = Event, +> { + days: (Day | null)[]; + weekStartsOn: number; + locale: string; +} + +type GroupDaysByMonthProps< + TResource extends Resource, + TEvent extends Event = Event, +> = GroupDaysByBaseProps & { + unit: 'month'; + fillMissingDays?: never; +} + +type GroupDaysByWeekProps< + TResource extends Resource, + TEvent extends Event = Event, +> = GroupDaysByBaseProps & { + unit: 'week' | 'workWeek'; + fillMissingDays?: boolean; +} + +export type GroupDaysByProps< + TResource extends Resource, + TEvent extends Event = Event, +> = + | GroupDaysByMonthProps + | GroupDaysByWeekProps; + +export const groupDaysBy = < + TResource extends Resource, + TEvent extends Event = Event, +>({ + days, + unit, + fillMissingDays = true, + weekStartsOn, + locale, +}: GroupDaysByProps): (Day< + TResource, + TEvent +> | null)[][] => { + const groups: (Day | null)[][] = []; + const loc = new Intl.Locale(locale); + const { weekend } = loc.getWeekInfo(); + + 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; + } + + case 'workWeek': { + const workWeeks: (Day | null)[][] = []; + let currentWorkWeek: (Day | null)[] = []; + + days.forEach((day) => { + if (currentWorkWeek.length === 0 && day?.date.dayOfWeek !== weekStartsOn) { + if (day) { + const dayOfWeek = (day.date.dayOfWeek - weekStartsOn + 7) % 7; + for (let i = 0; i < dayOfWeek; i++) { + const newDay = day.date.subtract({ days: dayOfWeek - i }); + if (!weekend.includes(newDay.dayOfWeek)) { + currentWorkWeek.push( + fillMissingDays + ? { + date: newDay, + events: [], + isToday: false, + isInCurrentPeriod: false, + } + : null, + ); + } + } + } + } + if (day && !weekend.includes(day.date.dayOfWeek)) { + currentWorkWeek.push(day); + } + if (currentWorkWeek.length === 5) { + workWeeks.push(currentWorkWeek); + currentWorkWeek = []; + } + }); + + if (currentWorkWeek.length > 0) { + while (currentWorkWeek.length < 5) { + const lastDate = + currentWorkWeek[currentWorkWeek.length - 1]?.date ?? + Temporal.PlainDate.from('2024-01-01'); + const nextDate = lastDate.add({ days: 1 }); + if (!weekend.includes(nextDate.dayOfWeek)) { + currentWorkWeek.push( + fillMissingDays + ? { + date: nextDate, + events: [], + isToday: false, + isInCurrentPeriod: false, + } + : null, + ); + } + } + workWeeks.push(currentWorkWeek); + } + + return workWeeks; + } + + 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..376460e --- /dev/null +++ b/packages/time/src/calendar/splitMultiDayEvents.ts @@ -0,0 +1,37 @@ +import { Temporal } from '@js-temporal/polyfill' +import { endOf, startOf } from '../utils' +import type { Event, Resource } from './types' + +export const splitMultiDayEvents = < + TResource extends Resource = Resource, + TEvent extends Event = Event, +>( + event: TEvent, + timeZone: Temporal.TimeZoneLike, +): TEvent[] => { + const startDate = Temporal.PlainDateTime.from(event.start).toZonedDateTime(timeZone) + const endDate = Temporal.PlainDateTime.from(event.end).toZonedDateTime(timeZone) + const events: TEvent[] = [] + + let currentDay = startDate + while (Temporal.ZonedDateTime.compare(currentDay, endDate) < 0) { + const eventStart = + Temporal.ZonedDateTime.compare(currentDay, startDate) === 0 + ? startDate + : startOf({ date: currentDay, unit: 'day' }) + const eventEnd = + Temporal.ZonedDateTime.compare(endDate, endOf({ date: currentDay, unit: 'day' })) < 0 + ? endDate + : endOf({ date: currentDay, unit: 'day' }) + + events.push({ + ...event, + start: eventStart.toString(), + end: eventEnd.toString(), + }) + + currentDay = startOf({ date: currentDay, unit: 'day' }).add({ days: 1 }) + } + + return events +} \ No newline at end of file diff --git a/packages/time/src/calendar/types.ts b/packages/time/src/calendar/types.ts new file mode 100644 index 0000000..5d7b888 --- /dev/null +++ b/packages/time/src/calendar/types.ts @@ -0,0 +1,30 @@ +import type { Temporal } from '@js-temporal/polyfill' + +export type Resource = string | null + +export interface Event { + id: string + start: string + end: string + title: string + resources?: TResource[] +} + +export interface CalendarStore { + currentPeriod: Temporal.PlainDate + activeDate: Temporal.PlainDate + viewMode: { + value: number + unit: 'month' | 'week' | 'workWeek' | 'day' + } +} + +export type Day< + TResource extends Resource = Resource, + TEvent extends Event = Event, +> = { + date: Temporal.PlainDate + events: TEvent[] + isToday: boolean + isInCurrentPeriod: boolean +} diff --git a/packages/time/src/core/calendar.ts b/packages/time/src/core/calendar.ts new file mode 100644 index 0000000..43aec57 --- /dev/null +++ b/packages/time/src/core/calendar.ts @@ -0,0 +1,427 @@ +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 { GroupDaysByProps } from '../calendar/groupDaysBy' +import type { CalendarStore, Day, Event, Resource } from '../calendar/types' + +import '@bart-krakowski/get-week-info-polyfill' + +export type * 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, workWeek or day). */ + unit: 'month' | 'week' | 'day' | 'workWeek' +} + +/** + * 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< + TResource extends Resource, + TEvent extends Event, +> { + /** 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 + /** Optional resources to be used in the calendar. */ + resources?: TResource[] | null +} + +/** + * 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< + TResource extends Resource, + TEvent extends Event, +> { + /** 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: string) => 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']) => { + isSplitEvent: boolean + overlappingEvents: TEvent[] + } | null + /** Retrieves the names of the days of the week, based on the current locale. */ + getDaysNames: (weekday?: 'long' | 'short') => string[] + /** Groups days by a specified unit. */ + groupDaysBy: ( + props: Omit, 'weekStartsOn' | 'locale'>, + ) => (Day | null)[][] +} + +interface CalendarState< + TResource extends Resource, + TEvent extends Event, +> { + /** The currently focused date period in the calendar. */ + currentPeriod: CalendarStore['currentPeriod'] + /** The current view mode of the calendar. */ + viewMode: CalendarStore['viewMode'] + /** An array of days, each potentially containing events. */ + days: Array> + /** The currently active date in the calendar. */ + activeDate: CalendarStore['activeDate'] +} + +type ConvertTemporalToString = { + [K in keyof T]: T[K] extends Temporal.PlainDate ? string : T[K] +} + +export interface CalendarApi< + TResource extends Resource, + TEvent extends Event, +> extends CalendarActions, + ConvertTemporalToString> {} + +/** + * 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< + TResource extends Resource, + TEvent extends Event, +> implements CalendarActions +{ + store: Store + options: Required> + + constructor(options: CalendarCoreOptions) { + const defaults = getDateDefaults() + this.options = { + ...defaults, + ...options, + events: options.events || null, + resources: options.resources || null, + } + + const now = Temporal.Now.plainDateISO().withCalendar(this.options.calendar) + + this.store = new Store({ + currentPeriod: now, + activeDate: now, + viewMode: options.viewMode, + }) + } + + + 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: Temporal.PlainDate + 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 + } + case 'workWeek': { + end = start.add({ days: 4 }) + break + } + } + + const allDays = generateDateRange(start.toString(), end.toString()) + const startMonthDate = this.store.state.currentPeriod.with({ day: 1 }) + const endMonthDate = this.store.state.currentPeriod + .add({ + months: this.store.state.viewMode.value - 1, + }) + .with({ + day: Temporal.PlainDate.from( + this.store.state.currentPeriod.toString({ calendarName: 'auto' }), + ).daysInMonth, + }) + + return allDays.filter( + (day) => + Temporal.PlainDate.compare(day, startMonthDate) >= 0 && + Temporal.PlainDate.compare(day, endMonthDate) <= 0, + ) + } + + private getEventMap() { + const map = new Map() + this.options.events?.forEach((event) => { + const eventStartDate = Temporal.PlainDateTime.from( + event.start, + ).toZonedDateTime(this.options.timeZone) + const eventEndDate = Temporal.PlainDateTime.from( + event.end, + ).toZonedDateTime(this.options.timeZone) + if (Temporal.ZonedDateTime.compare(eventStartDate, eventEndDate) !== 0) { + const splitEvents = splitMultiDayEvents( + event, + this.options.timeZone, + ) + splitEvents.forEach((splitEvent) => { + const [datePart] = splitEvent.start.toString().split('T') + if (datePart) { + if (!map.has(datePart)) map.set(datePart, []) + map.get(datePart)?.push(splitEvent) + } + }) + } else { + const [eventKey] = event.start.toString().split('T') + 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(weekday: 'long' | 'short' = 'short') { + const baseDate = Temporal.PlainDate.from('2024-01-01') + const firstDayOfWeek = this.getFirstDayOfWeek().dayOfWeek + + return Array.from({ length: 7 }).map((_, i) => + baseDate + .add({ days: (i + (firstDayOfWeek - 1)) % 7 }) + .toLocaleString(this.options.locale, { weekday: weekday }), + ) + } + + changeViewMode(newViewMode: CalendarStore['viewMode']) { + this.store.setState((prev) => ({ + ...prev, + viewMode: newViewMode, + })) + } + + goToPreviousPeriod() { + switch (this.store.state.viewMode.unit) { + case 'month': { + const newActiveDate = this.store.state.activeDate.subtract({ + months: this.store.state.viewMode.value, + }) + this.store.setState((prev) => ({ + ...prev, + activeDate: newActiveDate, + currentPeriod: newActiveDate, + })) + break + } + + case 'week': { + const newActiveDate = this.store.state.activeDate.subtract({ + weeks: this.store.state.viewMode.value, + }) + this.store.setState((prev) => ({ + ...prev, + activeDate: newActiveDate, + currentPeriod: newActiveDate, + })) + break + } + + case 'day': { + const newActiveDate = this.store.state.activeDate.subtract({ + days: this.store.state.viewMode.value, + }) + this.store.setState((prev) => ({ + ...prev, + activeDate: newActiveDate, + currentPeriod: newActiveDate, + })) + break + } + case 'workWeek': { + const newActiveDate = this.store.state.activeDate.subtract({ + days: 5, + }) + this.store.setState((prev) => ({ + ...prev, + activeDate: newActiveDate, + currentPeriod: newActiveDate, + })) + break + } + } + } + + goToNextPeriod() { + switch (this.store.state.viewMode.unit) { + case 'month': { + const newActiveDate = this.store.state.activeDate.add({ + months: this.store.state.viewMode.value, + }) + this.store.setState((prev) => ({ + ...prev, + activeDate: newActiveDate, + currentPeriod: newActiveDate, + })) + break + } + + case 'week': { + const newActiveDate = this.store.state.activeDate.add({ + weeks: this.store.state.viewMode.value, + }) + this.store.setState((prev) => ({ + ...prev, + activeDate: newActiveDate, + currentPeriod: newActiveDate, + })) + break + } + + case 'day': { + const newActiveDate = this.store.state.activeDate.add({ + days: this.store.state.viewMode.value, + }) + this.store.setState((prev) => ({ + ...prev, + activeDate: newActiveDate, + currentPeriod: newActiveDate, + })) + break + } + case 'workWeek': { + const newActiveDate = this.store.state.activeDate.add({ + days: 5, + }) + this.store.setState((prev) => ({ + ...prev, + activeDate: newActiveDate, + currentPeriod: newActiveDate, + })) + break + } + } + } + + goToCurrentPeriod() { + const now = Temporal.Now.plainDateISO() + this.store.setState((prev) => ({ + ...prev, + activeDate: now, + currentPeriod: now, + })) + } + + goToSpecificPeriod(date: string) { + this.store.setState((prev) => ({ + ...prev, + activeDate: Temporal.PlainDate.from(date), + currentPeriod: Temporal.PlainDate.from(date), + })) + } + + getEventProps(id: Event['id']) { + return getEventProps( + this.getEventMap(), + id, + this.store.state, + ) as ReturnType['getEventProps']> + } + + groupDaysBy({ + days, + unit, + fillMissingDays = true, + }: Omit, 'weekStartsOn' | 'locale'>) { + return groupDaysBy({ + days, + unit, + fillMissingDays, + weekStartsOn: this.getFirstDayOfWeek().dayOfWeek, + locale: this.options.locale, + } as GroupDaysByProps) + } +} 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/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..1d68f72 --- /dev/null +++ b/packages/time/src/tests/calendar-core.test.ts @@ -0,0 +1,327 @@ +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('2024-06-15') + const mockDateTime = Temporal.PlainDateTime.from('2024-06-15T10:00') + const mockTimeZone = 'America/New_York' + + beforeEach(() => { + vi.spyOn(Temporal.Now, 'plainDateISO').mockReturnValue(mockDate) + vi.spyOn(Temporal.Now, 'plainDateTimeISO').mockReturnValue(mockDateTime) + vi.spyOn(Temporal.Now, 'zonedDateTime').mockReturnValue( + Temporal.ZonedDateTime.from({ + timeZone: mockTimeZone, + year: 2024, + month: 6, + day: 15, + hour: 10, + minute: 0, + second: 0, + }), + ) + vi.spyOn(Temporal.Now, 'zonedDateTimeISO').mockReturnValue( + Temporal.ZonedDateTime.from({ + timeZone: mockTimeZone, + year: 2024, + month: 6, + day: 15, + hour: 10, + minute: 0, + second: 0, + }), + ) + + options = { + viewMode: { value: 1, unit: 'month' }, + events: [ + { + id: '1', + start: '2024-06-10T09:00', + end: '2024-06-10T10:00', + title: 'Event 1', + }, + { + id: '2', + start: '2024-06-12T11:00', + end: '2024-06-12T12:00', + title: 'Event 2', + }, + { + id: '3', + start: '2024-06-12T11:00', + end: '2024-06-12T13:00', + title: 'Event 3', + }, + ], + timeZone: mockTimeZone, + } + calendarCore = new CalendarCore(options) + }) + + describe('Initialization', () => { + test('should initialize with the correct current period', () => { + const today = Temporal.Now.plainDateISO() + expect(calendarCore.store.state.currentPeriod).toEqual(today) + expect(calendarCore.store.state.activeDate).toEqual(today) + }) + + 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) + expect(calendarCore.store.state.activeDate).toEqual(today) + }) + }) + + describe('Event mapping', () => { + 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('2024-06-10')), + ) + const dayWithEvent2 = daysWithEvents.find((day) => + day.date.equals(Temporal.PlainDate.from('2024-06-12')), + ) + expect(dayWithEvent1?.events).toHaveLength(1) + expect(dayWithEvent1?.events[0]?.id).toBe('1') + expect(dayWithEvent2?.events).toHaveLength(2) + expect(dayWithEvent2?.events[0]?.id).toBe('2') + expect(dayWithEvent2?.events[1]?.id).toBe('3') + }) + + test('should return the correct props for an event', () => { + calendarCore.changeViewMode({ value: 1, unit: 'day' }) + const eventProps = calendarCore.getEventProps('1') + expect(eventProps).toEqual({ + isSplitEvent: false, + overlappingEvents: [], + }) + }) + + test('should return the correct props for overlapping events', () => { + calendarCore.changeViewMode({ value: 1, unit: 'day' }) + const event1Props = calendarCore.getEventProps('2') + const event2Props = calendarCore.getEventProps('3') + + expect(event1Props).toEqual({ + isSplitEvent: false, + overlappingEvents: [ + { + ...options.events![2], + start: + Temporal.PlainDateTime.from('2024-06-12T11:00').toZonedDateTime( + mockTimeZone, + ).toString(), + end: Temporal.PlainDateTime.from( + '2024-06-12T13:00', + ).toZonedDateTime(mockTimeZone).toString(), + }, + ], + }) + + expect(event2Props).toEqual({ + isSplitEvent: false, + overlappingEvents: [ + { + ...options.events![1], + start: + Temporal.PlainDateTime.from('2024-06-12T11:00').toZonedDateTime( + mockTimeZone, + ).toString(), + end: Temporal.PlainDateTime.from( + '2024-06-12T12:00', + ).toZonedDateTime(mockTimeZone).toString(), + }, + ], + }) + }) + }) + + describe('View mode', () => { + 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 change view mode to workWeek correctly', () => { + calendarCore.changeViewMode({ value: 1, unit: 'workWeek' }) + expect(calendarCore.store.state.viewMode.value).toBe(1) + expect(calendarCore.store.state.viewMode.unit).toBe('workWeek') + }) + }) + + describe('Navigation', () => { + 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, + ) + expect(calendarCore.store.state.activeDate).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) + expect(calendarCore.store.state.activeDate).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) + expect(calendarCore.store.state.activeDate).toEqual(today) + }) + + test('should go to specific period correctly', () => { + const specificDate = '2024-07-01' + calendarCore.goToSpecificPeriod(specificDate) + expect(calendarCore.store.state.currentPeriod.toString()).toEqual( + specificDate, + ) + expect(calendarCore.store.state.activeDate.toString()).toEqual( + specificDate, + ) + }) + + test('should go to previous workWeek correctly', () => { + calendarCore.changeViewMode({ value: 1, unit: 'workWeek' }) + const initialPeriod = calendarCore.store.state.currentPeriod + calendarCore.goToPreviousPeriod() + const expectedPreviousWorkWeek = initialPeriod.subtract({ days: 7 }) + expect(calendarCore.store.state.currentPeriod).toEqual( + expectedPreviousWorkWeek, + ) + expect(calendarCore.store.state.activeDate).toEqual( + expectedPreviousWorkWeek, + ) + }) + + test('should go to next workWeek correctly', () => { + calendarCore.changeViewMode({ value: 1, unit: 'workWeek' }) + const initialPeriod = calendarCore.store.state.currentPeriod + calendarCore.goToNextPeriod() + const expectedNextWorkWeek = initialPeriod.add({ days: 7 }) + expect(calendarCore.store.state.currentPeriod).toEqual( + expectedNextWorkWeek, + ) + expect(calendarCore.store.state.activeDate).toEqual(expectedNextWorkWeek) + }) + }) + + describe('Days grouping', () => { + test('should group days correctly', () => { + const daysWithEvents = calendarCore.getDaysWithEvents() + const groupedDays = calendarCore.groupDaysBy({ + days: daysWithEvents, + unit: 'month', + }) + expect(groupedDays.length).toBeGreaterThan(0) + }) + + test('should group days by weeks correctly', () => { + const daysWithEvents = calendarCore.getDaysWithEvents() + const weeks = calendarCore.groupDaysBy({ + days: daysWithEvents, + unit: 'week', + }) + + expect(weeks).toHaveLength(6) + expect(weeks[0]?.[0]?.date.toString()).toBe('2024-05-26') + expect(weeks[5]?.[6]?.date.toString()).toBe('2024-07-06') + }) + + test('should group days by months correctly', () => { + const daysWithEvents = calendarCore.getDaysWithEvents() + const months = calendarCore.groupDaysBy({ + days: daysWithEvents, + unit: 'month', + }) + expect(months).toHaveLength(1) + expect(months[0]?.[0]?.date.toString()).toBe('2024-06-01') + }) + + test('should group days by workWeek correctly', () => { + const daysWithEvents = calendarCore.getDaysWithEvents() + const workWeeks = calendarCore.groupDaysBy({ + days: daysWithEvents, + unit: 'workWeek', + }) + + expect(workWeeks.length).toBe(9) + expect(workWeeks[0]?.length).toBe(5) + expect(workWeeks[0]?.[0]?.date.toString()).toBe('2024-05-27') + expect(workWeeks[0]?.[4]?.date.toString()).toBe('2024-05-31') + }) + + test('should group days by workWeek correctly with custom locale', () => { + const customLocale = 'pl' + calendarCore = new CalendarCore({ ...options, locale: customLocale }) + const daysWithEvents = calendarCore.getDaysWithEvents() + const workWeeks = calendarCore.groupDaysBy({ + days: daysWithEvents, + unit: 'workWeek', + }) + + expect(workWeeks.length).toBeGreaterThan(0) + expect(workWeeks[0]?.length).toBe(5) + expect(workWeeks[0]?.[0]?.date.toString()).toBe('2024-05-27') + expect(workWeeks[0]?.[4]?.date.toString()).toBe('2024-05-31') + }) + }) + + describe('Locale and timezone', () => { + test('should return the correct day names based on default locale', () => { + const daysNames = calendarCore.getDaysNames('short') + expect(daysNames).toEqual([ + 'Sun', + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + ]) + }) + + test('should return the correct day names based on custom locale', () => { + const customLocale = 'pl' + calendarCore = new CalendarCore({ ...options, locale: customLocale }) + const customDaysNames = calendarCore.getDaysNames('short') + expect(customDaysNames).toEqual([ + 'pon.', + 'wt.', + 'śr.', + 'czw.', + 'pt.', + 'sob.', + 'niedz.', + ]) + }) + }) +}) diff --git a/packages/time/src/tests/endOf.test.ts b/packages/time/src/tests/endOf.test.ts new file mode 100644 index 0000000..f8db833 --- /dev/null +++ b/packages/time/src/tests/endOf.test.ts @@ -0,0 +1,95 @@ +import { Temporal } from '@js-temporal/polyfill' +import { describe, expect, test } from 'vitest' +import { endOf } from '../utils' + +describe('endOf', () => { + test('should get the end of the given day', () => { + const date = Temporal.ZonedDateTime.from('2023-07-16T12:34:56.789+01:00[Europe/London]') + const result = endOf({ + date, + unit: 'day', + }) + const expected = Temporal.ZonedDateTime.from('2023-07-16T23:59:59.999999999+01:00[Europe/London]') + expect(result.equals(expected)).toBe(true) + }) + + test('should get the end of the given week', () => { + const date = Temporal.ZonedDateTime.from('2023-07-13T12:34:56.789+01:00[Europe/London]') + const result = endOf({ + date, + unit: 'week', + }) + const expected = Temporal.ZonedDateTime.from('2023-07-16T23:59:59.999999999+01:00[Europe/London]') + expect(result.equals(expected)).toBe(true) + }) + + test('should get the end of the given month', () => { + const date = Temporal.ZonedDateTime.from('2023-07-16T12:34:56.789+01:00[Europe/London]') + const result = endOf({ + date, + unit: 'month', + }) + const expected = Temporal.ZonedDateTime.from('2023-07-31T23:59:59.999999999+01:00[Europe/London]') + expect(result.equals(expected)).toBe(true) + }) + + test('should get the end of the given year', () => { + const date = Temporal.ZonedDateTime.from('2023-07-16T12:34:56.789+01:00[Europe/London]') + const result = endOf({ + date, + unit: 'year', + }) + const expected = Temporal.ZonedDateTime.from('2023-12-31T23:59:59.999999999+00:00[Europe/London]') + expect(result.equals(expected)).toBe(true) + }) + + test('should get the end of the given work week', () => { + const date = Temporal.ZonedDateTime.from('2023-07-10T12:34:56.789+01:00[Europe/London]') + const result = endOf({ + date, + unit: 'workWeek', + }) + const expected = Temporal.ZonedDateTime.from('2023-07-14T23:59:59.999999999+01:00[Europe/London]') + expect(result.equals(expected)).toBe(true) + }) + + test('should get the end of the given decade', () => { + const date = Temporal.ZonedDateTime.from('2023-07-16T12:34:56.789+01:00[Europe/London]') + const result = endOf({ + date, + unit: 'decade', + }) + const expected = Temporal.ZonedDateTime.from('2029-12-31T23:59:59.999999999+00:00[Europe/London]') + expect(result.equals(expected)).toBe(true) + }) + + test('should handle daylight saving time changes', () => { + // Test for a date just before the DST change + const dateBeforeDST = Temporal.ZonedDateTime.from('2023-10-28T12:00:00+01:00[Europe/London]') + const resultBeforeDST = endOf({ + date: dateBeforeDST, + unit: 'day', + }) + const expectedBeforeDST = Temporal.ZonedDateTime.from('2023-10-28T23:59:59.999999999+01:00[Europe/London]') + expect(resultBeforeDST.equals(expectedBeforeDST)).toBe(true) + + // Test for a date just after the DST change + const dateAfterDST = Temporal.ZonedDateTime.from('2023-10-29T12:00:00+00:00[Europe/London]') + const resultAfterDST = endOf({ + date: dateAfterDST, + unit: 'day', + }) + const expectedAfterDST = Temporal.ZonedDateTime.from('2023-10-29T23:59:59.999999999+00:00[Europe/London]') + expect(resultAfterDST.equals(expectedAfterDST)).toBe(true) + }) + + test('should handle different time zones', () => { + const dateNY = Temporal.ZonedDateTime.from('2023-07-16T12:34:56.789-04:00[America/New_York]') + const resultNY = endOf({ + date: dateNY, + unit: 'day', + }) + const expectedNY = Temporal.ZonedDateTime.from('2023-07-16T23:59:59.999999999-04:00[America/New_York]') + expect(resultNY.equals(expectedNY)).toBe(true) + }) +}) \ No newline at end of file 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/tests/startOf.test.ts b/packages/time/src/tests/startOf.test.ts new file mode 100644 index 0000000..ca26f90 --- /dev/null +++ b/packages/time/src/tests/startOf.test.ts @@ -0,0 +1,49 @@ + +import { Temporal } from '@js-temporal/polyfill' +import { describe, expect, test } from 'vitest' +import { startOf } from '../utils' + +describe('startOf', () => { + test('should get the start of the given day', () => { + const date = Temporal.ZonedDateTime.from('2023-07-16T12:34:56.789+01:00[Europe/London]') + const result = startOf(date, 'day') + const expected = Temporal.ZonedDateTime.from('2023-07-16T00:00:00.000+01:00[Europe/London]') + expect(result.equals(expected)).toBe(true) + }) + + test('should get the start of the given week', () => { + const date = Temporal.ZonedDateTime.from('2023-07-16T12:34:56.789+01:00[Europe/London]') + const result = startOf(date, 'week') + const expected = Temporal.ZonedDateTime.from('2023-07-10T00:00:00.000+01:00[Europe/London]') + expect(result.equals(expected)).toBe(true) + }) + + test('should get the start of the given month', () => { + const date = Temporal.ZonedDateTime.from('2023-07-16T12:34:56.789+01:00[Europe/London]') + const result = startOf(date, 'month') + const expected = Temporal.ZonedDateTime.from('2023-07-01T00:00:00.000+01:00[Europe/London]') + expect(result.equals(expected)).toBe(true) + }) + + test('should get the start of the given year', () => { + const date = Temporal.ZonedDateTime.from('2023-07-16T12:34:56.789+01:00[Europe/London]') + const result = startOf(date, 'year') + console.log('result', result.toString()) + const expected = Temporal.ZonedDateTime.from('2023-01-01T00:00:00.000+00:00[Europe/London]') + expect(result.equals(expected)).toBe(true) + }) + + test('should get the start of the given work week', () => { + const date = Temporal.ZonedDateTime.from('2023-07-16T12:34:56.789+01:00[Europe/London]') + const result = startOf(date, 'workWeek') + const expected = Temporal.ZonedDateTime.from('2023-07-10T00:00:00.000+01:00[Europe/London]') + expect(result.equals(expected)).toBe(true) + }) + + test('should get the start of the given decade', () => { + const date = Temporal.ZonedDateTime.from('2023-07-16T12:34:56.789+01:00[Europe/London]') + const result = startOf(date, 'decade') + const expected = Temporal.ZonedDateTime.from('2020-01-01T00:00:00.000+00:00[Europe/London]') + expect(result.equals(expected)).toBe(true) + }) +}) 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/endOf.ts b/packages/time/src/utils/endOf.ts new file mode 100644 index 0000000..db8e419 --- /dev/null +++ b/packages/time/src/utils/endOf.ts @@ -0,0 +1,68 @@ +import type { Temporal } from '@js-temporal/polyfill' + +type ViewUnit = 'month' | 'week' | 'day' | 'workWeek' | 'decade' | 'year' + +interface EndOfParams { + date: Temporal.ZonedDateTime + unit: ViewUnit + viewModeValue?: number + firstDayOfWeek?: number +} + +/** + * Helper function to get the end of a given temporal unit. + * @param {EndOfParams} params - The parameters for the endOf function. + * @returns {Temporal.ZonedDateTime} The end of the given unit. + */ +export function endOf({ + date, + unit, + viewModeValue = 1, + firstDayOfWeek = 1, +}: EndOfParams): Temporal.ZonedDateTime { + let endDate: Temporal.ZonedDateTime + + switch (unit) { + case 'day': + endDate = date + break + case 'week': + endDate = date.add({ days: 7 - date.dayOfWeek }) + break + case 'workWeek': + endDate = date.add({ days: 5 - date.dayOfWeek }) + break + case 'month': + endDate = date + .with({ day: 1 }) + .add({ months: viewModeValue }) + .subtract({ days: 1 }) + if (viewModeValue > 1) { + const lastDayOfMonthWeekDay = + (endDate.dayOfWeek - firstDayOfWeek + 7) % 7 + endDate = endDate.add({ days: 6 - lastDayOfMonthWeekDay }) + } + break + case 'year': + endDate = date.with({ month: 12, day: 31 }) + break + case 'decade': { + const lastYearOfDecade = date.with({ + year: date.year - (date.year % 10) + 9, + }) + endDate = lastYearOfDecade.with({ month: 12, day: 31 }) + break + } + default: + throw new Error(`Unsupported unit: ${unit}`) + } + + return endDate.with({ + hour: 23, + minute: 59, + second: 59, + millisecond: 999, + microsecond: 999, + nanosecond: 999, + }) +} 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..31224f1 --- /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.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..a744ffa --- /dev/null +++ b/packages/time/src/utils/index.ts @@ -0,0 +1,5 @@ +export * from './parse' +export * from './getFirstDayOfMonth' +export * from './getFirstDayOfWeek' +export * from './startOf' +export * from './endOf' diff --git a/packages/time/src/utils/startOf.ts b/packages/time/src/utils/startOf.ts new file mode 100644 index 0000000..67c492b --- /dev/null +++ b/packages/time/src/utils/startOf.ts @@ -0,0 +1,99 @@ +import type { Temporal } from '@js-temporal/polyfill' + +type ViewUnit = 'month' | 'week' | 'day' | 'workWeek' | 'decade' | 'year' + +interface StartOfParams { + date: Temporal.ZonedDateTime + unit: ViewUnit + firstDayOfWeek?: number +} + +/** + * Helper function to get the start of a given temporal unit. + * @param {StartOfParams} params - The parameters for the startOf function. + * @returns {Temporal.ZonedDateTime} The start of the given unit. + */ +export function startOf({ + date, + unit, + firstDayOfWeek = 1 +}: StartOfParams): Temporal.ZonedDateTime { + let startDate: Temporal.ZonedDateTime + + switch (unit) { + case 'day': + startDate = date.with({ + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + microsecond: 0, + nanosecond: 0 + }) + break + case 'week': { + const daysToSubtract = (date.dayOfWeek - firstDayOfWeek + 7) % 7 + startDate = date.subtract({ days: daysToSubtract }).with({ + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + microsecond: 0, + nanosecond: 0 + }) + break + } + case 'month': + startDate = date.with({ + day: 1, + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + microsecond: 0, + nanosecond: 0 + }) + break + case 'year': + startDate = date.with({ + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + microsecond: 0, + nanosecond: 0 + }) + break + case 'workWeek': { + const daysToSubtract = (date.dayOfWeek - 1 + 7) % 7 + startDate = date.subtract({ days: daysToSubtract }).with({ + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + microsecond: 0, + nanosecond: 0 + }) + break + } + case 'decade': + startDate = date.with({ + year: date.year - (date.year % 10), + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + microsecond: 0, + nanosecond: 0 + }) + break + default: + throw new Error(`Unsupported unit: ${unit}`) + } + + return startDate +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37c8fc7..e0c2ad8 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,21 @@ 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: + '@bart-krakowski/get-week-info-polyfill': + specifier: ^1.0.5 + version: 1.0.5 + '@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 +564,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 @@ -1820,6 +1843,10 @@ packages: '@babel/helper-validator-identifier': 7.22.20 to-fast-properties: 2.0.0 + /@bart-krakowski/get-week-info-polyfill@1.0.5: + resolution: {integrity: sha512-TE6yg50I2W0/EJ43OAD76TNleubb/P3w8DcxsGlUZIJNODtBjyqXyxyaj9vwiftb288TBl7Sfst2alNHSp4PCQ==} + dev: false + /@commitlint/parse@18.6.1: resolution: {integrity: sha512-eS/3GREtvVJqGZrwAGRwR9Gdno3YcZ6Xvuaa+vUF8j++wsmxrA2En3n0ccfVO2qVOLJC41ni7jSZhQiJpMPGOQ==} engines: {node: '>=v18'} @@ -2619,6 +2646,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 +3323,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 +5164,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 +5202,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 +7057,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 +7546,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 +7692,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 +7946,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 +9166,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 +9511,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 +9598,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 +10794,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 +10878,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'}