react-big-schedule 8.1.0 → 9.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -1129
- package/dist/calendar/Calendar.d.ts +28 -0
- package/dist/calendar/Calendar.js +95 -0
- package/dist/calendar/Toolbar.d.ts +18 -0
- package/dist/calendar/Toolbar.js +6 -0
- package/dist/calendar/recurrence.check.d.ts +1 -0
- package/dist/calendar/recurrence.check.js +49 -0
- package/dist/calendar/recurrence.d.ts +8 -0
- package/dist/calendar/recurrence.js +90 -0
- package/dist/calendar/timezone.d.ts +6 -0
- package/dist/calendar/timezone.js +14 -0
- package/dist/calendar/types.d.ts +187 -0
- package/dist/calendar/types.js +1 -0
- package/dist/calendar/useEventSources.d.ts +11 -0
- package/dist/calendar/useEventSources.js +89 -0
- package/dist/calendar/useRecurrence.d.ts +4 -0
- package/dist/calendar/useRecurrence.js +15 -0
- package/dist/calendar/utils.d.ts +38 -0
- package/dist/calendar/utils.js +143 -0
- package/dist/calendar/views/DayGridView.d.ts +3 -0
- package/dist/calendar/views/DayGridView.js +375 -0
- package/dist/calendar/views/ListView.d.ts +4 -0
- package/dist/calendar/views/ListView.js +56 -0
- package/dist/calendar/views/MultiMonthView.d.ts +4 -0
- package/dist/calendar/views/MultiMonthView.js +36 -0
- package/dist/calendar/views/TimeGridView.d.ts +3 -0
- package/dist/calendar/views/TimeGridView.js +334 -0
- package/dist/calendar/views/daygrid/DayCell.d.ts +19 -0
- package/dist/calendar/views/daygrid/DayCell.js +17 -0
- package/dist/calendar/views/daygrid/EventChip.d.ts +19 -0
- package/dist/calendar/views/daygrid/EventChip.js +40 -0
- package/dist/calendar/views/daygrid/layout.d.ts +30 -0
- package/dist/calendar/views/daygrid/layout.js +63 -0
- package/dist/calendar/views/list/bucket.d.ts +8 -0
- package/dist/calendar/views/list/bucket.js +37 -0
- package/dist/calendar/views/multimonth/Cell.d.ts +12 -0
- package/dist/calendar/views/multimonth/Cell.js +44 -0
- package/dist/calendar/views/multimonth/Continuous.d.ts +8 -0
- package/dist/calendar/views/multimonth/Continuous.js +28 -0
- package/dist/calendar/views/multimonth/MonthGrid.d.ts +10 -0
- package/dist/calendar/views/multimonth/MonthGrid.js +36 -0
- package/dist/calendar/views/multimonth/shared.d.ts +27 -0
- package/dist/calendar/views/multimonth/shared.js +40 -0
- package/dist/calendar/views/timegrid/DayColumn.d.ts +49 -0
- package/dist/calendar/views/timegrid/DayColumn.js +86 -0
- package/dist/calendar/views/timegrid/model.d.ts +55 -0
- package/dist/calendar/views/timegrid/model.js +102 -0
- package/dist/components/AddMore.d.ts +13 -0
- package/dist/components/AddMore.js +9 -42
- package/dist/components/AddMorePopover.d.ts +13 -0
- package/dist/components/AddMorePopover.js +24 -95
- package/dist/components/AgendaEventItem.d.ts +10 -0
- package/dist/components/AgendaEventItem.js +33 -81
- package/dist/components/AgendaResourceEvents.d.ts +10 -0
- package/dist/components/AgendaResourceEvents.js +37 -91
- package/dist/components/AgendaView.d.ts +9 -0
- package/dist/components/AgendaView.js +10 -69
- package/dist/components/BodyView.d.ts +11 -0
- package/dist/components/BodyView.js +36 -67
- package/dist/components/DnDContext.d.ts +48 -0
- package/dist/components/DnDContext.js +200 -221
- package/dist/components/DnDSource.d.ts +49 -0
- package/dist/components/DnDSource.js +125 -120
- package/dist/components/EventItem.d.ts +41 -0
- package/dist/components/EventItem.js +585 -787
- package/dist/components/EventItemPopover.d.ts +12 -0
- package/dist/components/EventItemPopover.js +19 -143
- package/dist/components/HeaderView.d.ts +14 -0
- package/dist/components/HeaderView.js +123 -186
- package/dist/components/ResourceEvents.d.ts +54 -0
- package/dist/components/ResourceEvents.js +534 -703
- package/dist/components/ResourceView.d.ts +26 -0
- package/dist/components/ResourceView.js +64 -184
- package/dist/components/SchedulerData.d.ts +136 -0
- package/dist/components/SchedulerData.js +1296 -1226
- package/dist/components/SchedulerHeader.d.ts +25 -0
- package/dist/components/SchedulerHeader.js +50 -150
- package/dist/components/SelectedArea.d.ts +8 -0
- package/dist/components/SelectedArea.js +11 -26
- package/dist/components/Summary.d.ts +11 -0
- package/dist/components/Summary.js +21 -50
- package/dist/components/WrapperFun.d.ts +2 -0
- package/dist/components/WrapperFun.js +5 -10
- package/dist/components/index.d.ts +44 -0
- package/dist/components/index.js +428 -654
- package/dist/components/ui/Icons.d.ts +8 -0
- package/dist/components/ui/Icons.js +7 -0
- package/dist/components/ui/MiniCalendar.d.ts +9 -0
- package/dist/components/ui/MiniCalendar.js +17 -0
- package/dist/components/ui/Popover.d.ts +13 -0
- package/dist/components/ui/Popover.js +80 -0
- package/dist/config/default.d.ts +36 -0
- package/dist/config/default.js +17 -26
- package/dist/config/i18n.d.ts +27 -0
- package/dist/config/i18n.js +21 -37
- package/dist/config/scheduler.d.ts +12 -0
- package/dist/config/scheduler.js +126 -119
- package/dist/css/style.css +2 -316
- package/dist/helper/behaviors.d.ts +14 -0
- package/dist/helper/behaviors.js +75 -75
- package/dist/helper/utility.d.ts +26 -0
- package/dist/helper/utility.js +23 -30
- package/dist/index.d.ts +6 -477
- package/dist/index.js +4 -2
- package/dist/sample-data/sample1.d.ts +11 -0
- package/dist/sample-data/sample1.js +427 -372
- package/dist/types.d.ts +267 -0
- package/dist/types.js +1 -0
- package/package.json +30 -27
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Dayjs } from 'dayjs';
|
|
2
|
+
import type { CalendarCallbacks, CalendarOptions, CalendarViewName, EventSource } from './types';
|
|
3
|
+
export interface CalendarProps extends CalendarOptions, CalendarCallbacks {
|
|
4
|
+
/** Events: an array, a `(range) => events` function, or a `{ url }` JSON feed. */
|
|
5
|
+
events?: EventSource;
|
|
6
|
+
/** More sources (arrays, functions, feeds, or `{ events, color, textColor }`) merged with `events`. */
|
|
7
|
+
eventSources?: EventSource[];
|
|
8
|
+
/** Controlled view. */
|
|
9
|
+
view?: CalendarViewName;
|
|
10
|
+
initialView?: CalendarViewName;
|
|
11
|
+
onViewChange?: (view: CalendarViewName) => void;
|
|
12
|
+
/** Controlled date (anchor of the visible range). */
|
|
13
|
+
date?: Dayjs | string | Date;
|
|
14
|
+
initialDate?: Dayjs | string | Date;
|
|
15
|
+
onDateChange?: (date: Dayjs) => void;
|
|
16
|
+
/** Views offered in the toolbar switcher. */
|
|
17
|
+
views?: CalendarViewName[];
|
|
18
|
+
/** Set false to hide the built-in toolbar and drive view/date yourself. */
|
|
19
|
+
toolbar?: boolean;
|
|
20
|
+
/** Colour scheme: 'default' (transparent, follows the page), 'light', 'dark' or any custom data-rbs-theme value. */
|
|
21
|
+
theme?: string;
|
|
22
|
+
/** Design language: 'material' | 'fluent' | 'tailwind' | 'minimal' | 'classic'. */
|
|
23
|
+
look?: string;
|
|
24
|
+
/** Colour palette: blue, green, purple, red, amber, emerald, indigo or rose. */
|
|
25
|
+
palette?: string;
|
|
26
|
+
}
|
|
27
|
+
declare function Calendar({ events: eventsProp, eventSources, view: viewProp, initialView, onViewChange, date: dateProp, initialDate, onDateChange, views, toolbar, theme, look, palette, ...rest }: CalendarProps): import("react").JSX.Element;
|
|
28
|
+
export default Calendar;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import SchedulerData from '../components/SchedulerData';
|
|
4
|
+
import { ViewType } from '../config/default';
|
|
5
|
+
import Toolbar from './Toolbar';
|
|
6
|
+
import { withTimeZone } from './timezone';
|
|
7
|
+
import { useEventSources } from './useEventSources';
|
|
8
|
+
import { useRecurrence } from './useRecurrence';
|
|
9
|
+
import { getViewRange, getViewTitle, stepDate } from './utils';
|
|
10
|
+
const DayGridView = lazy(() => import('./views/DayGridView'));
|
|
11
|
+
const TimeGridView = lazy(() => import('./views/TimeGridView'));
|
|
12
|
+
const ListView = lazy(() => import('./views/ListView'));
|
|
13
|
+
const MultiMonthView = lazy(() => import('./views/MultiMonthView'));
|
|
14
|
+
const VIEW_LABELS = {
|
|
15
|
+
dayGridMonth: 'Month',
|
|
16
|
+
dayGridWeek: 'Week grid',
|
|
17
|
+
dayGridDay: 'Day grid',
|
|
18
|
+
timeGridWeek: 'Week',
|
|
19
|
+
timeGridDay: 'Day',
|
|
20
|
+
listDay: 'List day',
|
|
21
|
+
listWeek: 'List',
|
|
22
|
+
listMonth: 'List month',
|
|
23
|
+
listYear: 'List year',
|
|
24
|
+
multiMonthYear: 'Year',
|
|
25
|
+
multiMonthStack: 'Year stack',
|
|
26
|
+
multiMonthContinuous: 'Year flow',
|
|
27
|
+
};
|
|
28
|
+
const DEFAULT_VIEWS = ['dayGridMonth', 'timeGridWeek', 'timeGridDay', 'listWeek', 'multiMonthYear'];
|
|
29
|
+
function pickView(view) {
|
|
30
|
+
if (view.startsWith('dayGrid'))
|
|
31
|
+
return DayGridView;
|
|
32
|
+
if (view.startsWith('timeGrid'))
|
|
33
|
+
return TimeGridView;
|
|
34
|
+
if (view.startsWith('list'))
|
|
35
|
+
return ListView;
|
|
36
|
+
return MultiMonthView;
|
|
37
|
+
}
|
|
38
|
+
function Calendar({ events: eventsProp, eventSources, view: viewProp, initialView = 'dayGridMonth', onViewChange, date: dateProp, initialDate, onDateChange, views = DEFAULT_VIEWS, toolbar = true, theme, look, palette, ...rest }) {
|
|
39
|
+
// one locale-bound dayjs per calendar (same isolation as <Scheduler>, see #146)
|
|
40
|
+
const baseDayjs = useMemo(() => new SchedulerData(undefined, ViewType.Week, false, false).localeDayjs, []);
|
|
41
|
+
useMemo(() => baseDayjs.locale(rest.locale ?? baseDayjs.locale()), [baseDayjs, rest.locale]);
|
|
42
|
+
const dayjs = useMemo(() => withTimeZone(baseDayjs, rest.timeZone), [baseDayjs, rest.timeZone]);
|
|
43
|
+
const [innerView, setInnerView] = useState(initialView);
|
|
44
|
+
const [innerDate, setInnerDate] = useState(() => dayjs(initialDate));
|
|
45
|
+
const view = viewProp ?? innerView;
|
|
46
|
+
const date = useMemo(() => (dateProp === undefined ? dayjs(innerDate.valueOf()) : dayjs(dateProp)), [dayjs, dateProp, innerDate]);
|
|
47
|
+
const setView = useCallback((next) => {
|
|
48
|
+
if (viewProp === undefined)
|
|
49
|
+
setInnerView(next);
|
|
50
|
+
onViewChange?.(next);
|
|
51
|
+
}, [viewProp, onViewChange]);
|
|
52
|
+
const setDate = useCallback((next) => {
|
|
53
|
+
if (dateProp === undefined)
|
|
54
|
+
setInnerDate(next);
|
|
55
|
+
onDateChange?.(next);
|
|
56
|
+
}, [dateProp, onDateChange]);
|
|
57
|
+
const goTo = useCallback((nextView, nextDate) => {
|
|
58
|
+
setView(nextView);
|
|
59
|
+
setDate(nextDate);
|
|
60
|
+
}, [setView, setDate]);
|
|
61
|
+
const range = useMemo(() => getViewRange(view, date, rest.firstDay ?? 0), [view, date, rest.firstDay]);
|
|
62
|
+
const { onDatesSet, onLoading, onEventsError } = rest;
|
|
63
|
+
const sourced = useEventSources(eventsProp, eventSources, range, onLoading, onEventsError);
|
|
64
|
+
const events = useRecurrence(sourced.events, range, dayjs);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
onDatesSet?.({ ...range, view });
|
|
67
|
+
}, [range.start.valueOf(), range.end.valueOf(), view]);
|
|
68
|
+
// one delegated hover card for every view: any element carrying data-tip (title line, then detail lines)
|
|
69
|
+
const [tip, setTip] = useState(null);
|
|
70
|
+
const showTip = (target, pressed = false) => {
|
|
71
|
+
const el = pressed ? null : target.closest('[data-tip]');
|
|
72
|
+
if (!el?.dataset.tip)
|
|
73
|
+
return setTip(null);
|
|
74
|
+
const r = el.getBoundingClientRect();
|
|
75
|
+
setTip(t => t && t.text === el.dataset.tip && t.top === r.top
|
|
76
|
+
? t
|
|
77
|
+
: {
|
|
78
|
+
x: r.left + r.width / 2,
|
|
79
|
+
top: r.top,
|
|
80
|
+
bottom: r.bottom,
|
|
81
|
+
text: el.dataset.tip ?? '',
|
|
82
|
+
color: el.dataset.tipColor,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const [tipTitle, ...tipLines] = tip?.text.split('\n') ?? [];
|
|
86
|
+
const View = pickView(view);
|
|
87
|
+
const viewProps = { ...rest, view, date, events, dayjs, goTo };
|
|
88
|
+
return (_jsxs("div", { "data-rbs-theme": theme, "data-rbs-look": look, "data-rbs-palette": palette, className: `rbs-calendar rbs-container ${rest.className ?? ''}`, style: { height: rest.height, ...rest.style }, onMouseOver: e => showTip(e.target, e.buttons > 0), onFocus: e => showTip(e.target), onBlur: () => setTip(null), onMouseLeave: () => setTip(null), onScrollCapture: () => setTip(null), children: [toolbar && (_jsx(Toolbar, { title: getViewTitle(view, date, range), view: view, views: views.map(name => ({ name, label: VIEW_LABELS[name] })), today: dayjs(), loading: sourced.loading, onPrev: () => setDate(stepDate(view, date, -1)), onNext: () => setDate(stepDate(view, date, 1)), onToday: () => setDate(dayjs()), onView: setView })), _jsx(Suspense, { fallback: _jsx("span", { className: "rbs-spinner", role: "status", "aria-label": "Loading" }), children: _jsx(View, { ...viewProps }) }), tip && (_jsxs("div", { role: "tooltip", className: "rbs-tip", style: {
|
|
89
|
+
left: Math.min(Math.max(tip.x, 150), window.innerWidth - 150),
|
|
90
|
+
...(tip.top > 110
|
|
91
|
+
? { top: tip.top - 8, transform: 'translate(-50%, -100%)' }
|
|
92
|
+
: { top: tip.bottom + 8, transform: 'translateX(-50%)' }),
|
|
93
|
+
}, children: [_jsx("span", { className: "rbs-tip-bar", style: { background: tip.color ?? 'var(--rbs-accent)' } }), _jsx("strong", { children: tipTitle }), tipLines.map(l => (_jsx("span", { children: l }, l)))] }))] }));
|
|
94
|
+
}
|
|
95
|
+
export default Calendar;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Dayjs } from 'dayjs';
|
|
2
|
+
import type { CalendarViewName } from './types';
|
|
3
|
+
export interface ToolbarProps {
|
|
4
|
+
title: string;
|
|
5
|
+
view: CalendarViewName;
|
|
6
|
+
views: {
|
|
7
|
+
name: CalendarViewName;
|
|
8
|
+
label: string;
|
|
9
|
+
}[];
|
|
10
|
+
onPrev: () => void;
|
|
11
|
+
onNext: () => void;
|
|
12
|
+
onToday: () => void;
|
|
13
|
+
onView: (view: CalendarViewName) => void;
|
|
14
|
+
today: Dayjs;
|
|
15
|
+
loading?: boolean;
|
|
16
|
+
}
|
|
17
|
+
declare function Toolbar({ title, view, views, onPrev, onNext, onToday, onView, loading }: ToolbarProps): import("react").JSX.Element;
|
|
18
|
+
export default Toolbar;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ChevronLeft, ChevronRight } from '../components/ui/Icons';
|
|
3
|
+
function Toolbar({ title, view, views, onPrev, onNext, onToday, onView, loading }) {
|
|
4
|
+
return (_jsxs("div", { className: "rbs-cal-toolbar rbs:flex rbs:flex-wrap rbs:items-center rbs:justify-between rbs:gap-2 rbs:pb-3", children: [_jsxs("div", { className: "rbs:flex rbs:items-center rbs:gap-2", children: [_jsx("button", { type: "button", "aria-label": "Previous", className: "rbs-icon-btn", onClick: onPrev, children: _jsx(ChevronLeft, {}) }), _jsx("button", { type: "button", "aria-label": "Next", className: "rbs-icon-btn", onClick: onNext, children: _jsx(ChevronRight, {}) }), _jsx("button", { type: "button", className: "rbs-btn", onClick: onToday, children: "Today" }), loading && _jsx("span", { className: "rbs-spinner", role: "status", "aria-label": "Loading events" })] }), _jsx("h2", { className: "rbs-cal-title rbs:m-0 rbs:text-lg rbs:font-medium", children: title }), _jsx("div", { className: "rbs-segmented", role: "group", "aria-label": "View", children: views.map(v => (_jsx("button", { type: "button", "aria-pressed": v.name === view, onClick: () => onView(v.name), children: v.label }, v.name))) })] }));
|
|
5
|
+
}
|
|
6
|
+
export default Toolbar;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Run: bun src/calendar/recurrence.check.ts
|
|
2
|
+
import SchedulerData from '../components/SchedulerData';
|
|
3
|
+
import { ViewType } from '../config/default';
|
|
4
|
+
import { expandRecurring } from './recurrence';
|
|
5
|
+
import { withTimeZone } from './timezone';
|
|
6
|
+
const assert = {
|
|
7
|
+
equal: (a, b) => {
|
|
8
|
+
if (a !== b)
|
|
9
|
+
throw new Error(`expected ${String(b)}, got ${String(a)}`);
|
|
10
|
+
},
|
|
11
|
+
deepEqual: (a, b) => assert.equal(JSON.stringify(a), JSON.stringify(b)),
|
|
12
|
+
ok: (v) => assert.equal(Boolean(v), true),
|
|
13
|
+
};
|
|
14
|
+
const base = new SchedulerData(undefined, ViewType.Week, false, false).localeDayjs;
|
|
15
|
+
const utc = withTimeZone(base, 'UTC');
|
|
16
|
+
const range = { start: utc('2024-03-04'), end: utc('2024-03-18') }; // two weeks, Mon..Mon
|
|
17
|
+
// rrule: weekly Mon/Wed, 1h, one wed skipped via exceptions
|
|
18
|
+
const weekly = {
|
|
19
|
+
id: 'a',
|
|
20
|
+
title: 'Standup',
|
|
21
|
+
start: '2024-03-01T09:00:00',
|
|
22
|
+
end: '2024-03-01T10:00:00',
|
|
23
|
+
rrule: 'FREQ=WEEKLY;BYDAY=MO,WE',
|
|
24
|
+
exceptions: ['2024-03-13T09:00:00'],
|
|
25
|
+
};
|
|
26
|
+
const occ = expandRecurring([weekly], range, utc);
|
|
27
|
+
assert.deepEqual(occ.map(o => String(o.start).slice(0, 16)), ['2024-03-04T09:00', '2024-03-06T09:00', '2024-03-11T09:00']);
|
|
28
|
+
assert.ok(String(occ[0]?.id).startsWith('a:2024-03-04T09:00'));
|
|
29
|
+
assert.equal(occ[0]?.extendedProps?.recurringId, 'a');
|
|
30
|
+
assert.equal(occ[0]?.end && String(occ[0].end).slice(11, 16), '10:00');
|
|
31
|
+
// simple form
|
|
32
|
+
const simple = {
|
|
33
|
+
id: 's',
|
|
34
|
+
title: 'Gym',
|
|
35
|
+
start: '2024-01-01',
|
|
36
|
+
recurrence: { daysOfWeek: [2, 4], startTime: '07:30', endTime: '08:15', endRecur: '2024-03-13' },
|
|
37
|
+
};
|
|
38
|
+
const so = expandRecurring([simple], range, utc);
|
|
39
|
+
assert.deepEqual(so.map(o => String(o.start).slice(0, 16)), ['2024-03-05T07:30', '2024-03-07T07:30', '2024-03-12T07:30']);
|
|
40
|
+
// plain events pass through untouched
|
|
41
|
+
const plain = { id: 'p', title: 'x', start: '2024-03-05' };
|
|
42
|
+
assert.equal(expandRecurring([plain], range, utc)[0], plain);
|
|
43
|
+
// time zones: 2024-03-01T00:00:00Z is 05:30 in Kolkata and 19:00 (Feb 29) in New York; offset-less string is wall time
|
|
44
|
+
const inst = '2024-03-01T00:00:00Z';
|
|
45
|
+
assert.equal(withTimeZone(base, 'Asia/Kolkata')(inst).format('YYYY-MM-DD HH:mm'), '2024-03-01 05:30');
|
|
46
|
+
assert.equal(withTimeZone(base, 'America/New_York')(inst).format('YYYY-MM-DD HH:mm'), '2024-02-29 19:00');
|
|
47
|
+
assert.equal(withTimeZone(base, 'Asia/Kolkata')('2024-03-01T05:30:00').toISOString(), '2024-03-01T00:00:00.000Z');
|
|
48
|
+
assert.equal(withTimeZone(base, 'UTC')('2024-03-01').format('YYYY-MM-DD HH:mm'), '2024-03-01 00:00');
|
|
49
|
+
console.log('recurrence.check: ok');
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { LocaleDayjs } from '../types';
|
|
2
|
+
import type { CalendarEvent, DateRange } from './types';
|
|
3
|
+
export declare const isRecurring: (e: CalendarEvent) => boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Replace recurring events by their occurrences inside `range`; other events pass through untouched.
|
|
6
|
+
* Cost is proportional to the visible range, never to the length of the recurrence.
|
|
7
|
+
*/
|
|
8
|
+
export declare function expandRecurring(events: CalendarEvent[], range: DateRange, dayjs: LocaleDayjs): CalendarEvent[];
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { rrulestr } from 'rrule';
|
|
2
|
+
import { normalizeEvent, parseTime, toDayjs } from './utils';
|
|
3
|
+
const MAX_PER_EVENT = 2000;
|
|
4
|
+
const DAY = 86400000;
|
|
5
|
+
export const isRecurring = (e) => Boolean(e.rrule || e.recurrence);
|
|
6
|
+
// rrule computes in UTC fields; feed it wall-clock fields as if they were UTC ("floating" time) so DST and zones stay right
|
|
7
|
+
const toFloating = (d) => new Date(Date.UTC(d.year(), d.month(), d.date(), d.hour(), d.minute(), d.second()));
|
|
8
|
+
const fromFloating = (dayjs, d) => dayjs(d.toISOString().slice(0, 19));
|
|
9
|
+
function occurrence(ev, start, end, allDay) {
|
|
10
|
+
const { rrule: _r, recurrence: _c, exceptions: _x, ...rest } = ev;
|
|
11
|
+
const fmt = (d) => (allDay ? d.format('YYYY-MM-DD') : d.format());
|
|
12
|
+
return {
|
|
13
|
+
...rest,
|
|
14
|
+
id: `${ev.id}:${start.format()}`,
|
|
15
|
+
start: fmt(start),
|
|
16
|
+
end: fmt(end),
|
|
17
|
+
allDay,
|
|
18
|
+
extendedProps: { ...ev.extendedProps, recurringId: ev.id },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function rruleStarts(dayjs, ev, start, from, to) {
|
|
22
|
+
// bare "FREQ=..." lines need their RRULE: prefix for set parsing
|
|
23
|
+
const text = ev.rrule
|
|
24
|
+
.split(/\r?\n/)
|
|
25
|
+
.map(l => (/^(RRULE|EXRULE|DTSTART|EXDATE|RDATE)/i.test(l.trim()) ? l.trim() : `RRULE:${l.trim()}`))
|
|
26
|
+
.join('\n');
|
|
27
|
+
const dtstart = `DTSTART:${toFloating(start)
|
|
28
|
+
.toISOString()
|
|
29
|
+
.replace(/[-:]|\.\d{3}/g, '')}`;
|
|
30
|
+
const set = rrulestr(/^DTSTART/im.test(text) ? text : `${dtstart}\n${text}`, { forceset: true });
|
|
31
|
+
return set
|
|
32
|
+
.between(toFloating(from), toFloating(to), true)
|
|
33
|
+
.slice(0, MAX_PER_EVENT)
|
|
34
|
+
.map(d => fromFloating(dayjs, d));
|
|
35
|
+
}
|
|
36
|
+
function simpleStarts(dayjs, ev, from, to) {
|
|
37
|
+
const r = ev.recurrence;
|
|
38
|
+
if (!r)
|
|
39
|
+
return [];
|
|
40
|
+
const days = new Set(r.daysOfWeek);
|
|
41
|
+
const lo = r.startRecur === undefined ? undefined : toDayjs(dayjs, r.startRecur).startOf('day');
|
|
42
|
+
const hi = r.endRecur === undefined ? undefined : toDayjs(dayjs, r.endRecur).startOf('day');
|
|
43
|
+
const out = [];
|
|
44
|
+
for (let d = from.startOf('day'); d.isBefore(to) && out.length < MAX_PER_EVENT; d = d.add(1, 'day')) {
|
|
45
|
+
if (!days.has(d.day()) || (lo && d.isBefore(lo)) || (hi && !d.isBefore(hi)))
|
|
46
|
+
continue;
|
|
47
|
+
if (r.startTime === undefined) {
|
|
48
|
+
out.push({ start: d, allDay: true });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const at = (t) => d.add(parseTime(t), 'minute');
|
|
52
|
+
out.push({ start: at(r.startTime), end: r.endTime === undefined ? undefined : at(r.endTime), allDay: false });
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Replace recurring events by their occurrences inside `range`; other events pass through untouched.
|
|
58
|
+
* Cost is proportional to the visible range, never to the length of the recurrence.
|
|
59
|
+
*/
|
|
60
|
+
export function expandRecurring(events, range, dayjs) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const ev of events) {
|
|
63
|
+
if (!isRecurring(ev)) {
|
|
64
|
+
out.push(ev);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const base = normalizeEvent(dayjs, ev);
|
|
68
|
+
const dur = base.end.diff(base.start);
|
|
69
|
+
const skip = new Set((ev.exceptions ?? []).map(x => {
|
|
70
|
+
const d = toDayjs(dayjs, x);
|
|
71
|
+
return base.allDay ? d.format('YYYY-MM-DD') : String(d.valueOf());
|
|
72
|
+
}));
|
|
73
|
+
const isSkipped = (s) => skip.has(base.allDay ? s.format('YYYY-MM-DD') : String(s.valueOf()));
|
|
74
|
+
const span = (s) => base.allDay ? s.add(Math.max(1, Math.round(dur / DAY)), 'day') : s.add(dur, 'millisecond');
|
|
75
|
+
const from = range.start.subtract(dur, 'millisecond');
|
|
76
|
+
if (ev.rrule) {
|
|
77
|
+
for (const s of rruleStarts(dayjs, ev, base.start, from, range.end)) {
|
|
78
|
+
if (!isSkipped(s))
|
|
79
|
+
out.push(occurrence(ev, s, span(s), base.allDay));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const o of simpleStarts(dayjs, ev, from, range.end)) {
|
|
83
|
+
const s = o.start;
|
|
84
|
+
const e = o.end?.isAfter(s) ? o.end : o.allDay ? s.add(Math.max(1, Math.round(dur / DAY)), 'day') : span(s);
|
|
85
|
+
if (!isSkipped(s) && e.isAfter(range.start))
|
|
86
|
+
out.push(occurrence(ev, s, e, o.allDay));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { LocaleDayjs } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Bind a locale dayjs to a time zone. Instants (offset strings, Dates, timestamps, Dayjs) are converted to the zone;
|
|
4
|
+
* offset-less strings are read as wall-clock time in it.
|
|
5
|
+
*/
|
|
6
|
+
export declare function withTimeZone(base: LocaleDayjs, zone: string | undefined): LocaleDayjs;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// "2024-03-01T10:00:00Z", "...+05:30" (a date-only string never matches: it needs a time part)
|
|
2
|
+
const HAS_OFFSET = /\d[T ]\d.*(?:[zZ]|[+-]\d{2}(?::?\d{2})?)$/;
|
|
3
|
+
/**
|
|
4
|
+
* Bind a locale dayjs to a time zone. Instants (offset strings, Dates, timestamps, Dayjs) are converted to the zone;
|
|
5
|
+
* offset-less strings are read as wall-clock time in it.
|
|
6
|
+
*/
|
|
7
|
+
export function withTimeZone(base, zone) {
|
|
8
|
+
if (!zone || zone === 'local')
|
|
9
|
+
return base;
|
|
10
|
+
const create = (date, format, strict) => typeof date === 'string' && !format && !HAS_OFFSET.test(date)
|
|
11
|
+
? base.tz(date, zone)
|
|
12
|
+
: base(date, format, strict).tz(zone);
|
|
13
|
+
return Object.assign(create, { locale: base.locale, utc: base.utc, tz: base.tz });
|
|
14
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { Dayjs } from 'dayjs';
|
|
2
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
3
|
+
import type { LocaleDayjs } from '../types';
|
|
4
|
+
/** Every built-in calendar view (FullCalendar naming). Resource timeline stays in <Scheduler>. */
|
|
5
|
+
export type CalendarViewName = 'dayGridMonth' | 'dayGridWeek' | 'dayGridDay' | 'timeGridWeek' | 'timeGridDay' | 'listDay' | 'listWeek' | 'listMonth' | 'listYear' | 'multiMonthYear' | 'multiMonthStack' | 'multiMonthContinuous';
|
|
6
|
+
export type DateLike = string | number | Date | Dayjs;
|
|
7
|
+
export interface CalendarEvent {
|
|
8
|
+
id: string | number;
|
|
9
|
+
title: string;
|
|
10
|
+
start: DateLike;
|
|
11
|
+
end?: DateLike;
|
|
12
|
+
allDay?: boolean;
|
|
13
|
+
/** Background colour of the event; falls back to the theme accent. */
|
|
14
|
+
color?: string;
|
|
15
|
+
textColor?: string;
|
|
16
|
+
/** 'background' paints the time range behind other events instead of an event chip. */
|
|
17
|
+
display?: 'auto' | 'block' | 'list-item' | 'background' | 'none';
|
|
18
|
+
editable?: boolean;
|
|
19
|
+
startEditable?: boolean;
|
|
20
|
+
durationEditable?: boolean;
|
|
21
|
+
/** Free-form data, handed back untouched in every callback. */
|
|
22
|
+
extendedProps?: Record<string, unknown>;
|
|
23
|
+
classNames?: string[];
|
|
24
|
+
/**
|
|
25
|
+
* RFC 5545 rule (`FREQ=WEEKLY;BYDAY=MO,WE`, optionally with DTSTART/EXDATE lines). Expanded for the visible range only.
|
|
26
|
+
* DTSTART defaults to `start`; an embedded DTSTART is read as wall-clock time in the calendar's time zone.
|
|
27
|
+
* The event's `start`/`end` give the first occurrence and the duration of every occurrence.
|
|
28
|
+
*/
|
|
29
|
+
rrule?: string;
|
|
30
|
+
/** FullCalendar-style simple recurrence (weekly days + time of day). Takes `start`/`end` duration when no times are given. */
|
|
31
|
+
recurrence?: SimpleRecurrence;
|
|
32
|
+
/** Occurrences to skip (matched by start instant, or by day for all-day events). Applies to `rrule` and `recurrence`. */
|
|
33
|
+
exceptions?: DateLike[];
|
|
34
|
+
}
|
|
35
|
+
/** Weekly recurrence without RRULE syntax. */
|
|
36
|
+
export interface SimpleRecurrence {
|
|
37
|
+
/** 0 = Sunday ... 6 = Saturday. */
|
|
38
|
+
daysOfWeek: number[];
|
|
39
|
+
/** 'HH:mm'. Omit both times for all-day occurrences. */
|
|
40
|
+
startTime?: string;
|
|
41
|
+
/** 'HH:mm'. Omit to reuse the duration of the event. */
|
|
42
|
+
endTime?: string;
|
|
43
|
+
/** First day (inclusive) the recurrence applies. */
|
|
44
|
+
startRecur?: DateLike;
|
|
45
|
+
/** Day (exclusive) the recurrence stops, like FullCalendar. */
|
|
46
|
+
endRecur?: DateLike;
|
|
47
|
+
}
|
|
48
|
+
/** Visible range handed to dynamic event sources. */
|
|
49
|
+
export type EventFetchRange = DateRange;
|
|
50
|
+
/** Function source. Receives an AbortSignal that fires when the user navigates away before it resolves. */
|
|
51
|
+
export type EventFetcher = (range: EventFetchRange, signal: AbortSignal) => CalendarEvent[] | Promise<CalendarEvent[]>;
|
|
52
|
+
/** JSON feed source: GET (or POST) `url` with `start` / `end` ISO query params, expects a CalendarEvent[] response. */
|
|
53
|
+
export interface EventFeed {
|
|
54
|
+
url: string;
|
|
55
|
+
method?: 'GET' | 'POST';
|
|
56
|
+
/** Extra query params (form body for POST); a function is evaluated on every fetch. */
|
|
57
|
+
params?: Record<string, string | number | boolean> | (() => Record<string, string | number | boolean>);
|
|
58
|
+
/** Same as `params`; merged on top of it (FullCalendar's extraParams). */
|
|
59
|
+
extraParams?: Record<string, string | number | boolean> | (() => Record<string, string | number | boolean>);
|
|
60
|
+
headers?: Record<string, string>;
|
|
61
|
+
/** Query param names. Default 'start' / 'end'. */
|
|
62
|
+
startParam?: string;
|
|
63
|
+
endParam?: string;
|
|
64
|
+
/** Default colours for events of this feed. */
|
|
65
|
+
color?: string;
|
|
66
|
+
textColor?: string;
|
|
67
|
+
}
|
|
68
|
+
export type EventSourceInput = CalendarEvent[] | EventFetcher | EventFeed;
|
|
69
|
+
/** Any source wrapped with colours that fill in events lacking their own. */
|
|
70
|
+
export interface EventSourceObject {
|
|
71
|
+
events: EventSourceInput;
|
|
72
|
+
color?: string;
|
|
73
|
+
textColor?: string;
|
|
74
|
+
}
|
|
75
|
+
export type EventSource = EventSourceInput | EventSourceObject;
|
|
76
|
+
export interface BusinessHours {
|
|
77
|
+
/** 0 = Sunday ... 6 = Saturday. Default Mon-Fri. */
|
|
78
|
+
daysOfWeek?: number[];
|
|
79
|
+
/** 'HH:mm' */
|
|
80
|
+
startTime?: string;
|
|
81
|
+
endTime?: string;
|
|
82
|
+
}
|
|
83
|
+
export interface DateRange {
|
|
84
|
+
start: Dayjs;
|
|
85
|
+
end: Dayjs;
|
|
86
|
+
}
|
|
87
|
+
export interface EventClickInfo {
|
|
88
|
+
event: CalendarEvent;
|
|
89
|
+
jsEvent: React.MouseEvent | MouseEvent;
|
|
90
|
+
}
|
|
91
|
+
export interface DateClickInfo {
|
|
92
|
+
date: Dayjs;
|
|
93
|
+
allDay: boolean;
|
|
94
|
+
jsEvent: React.MouseEvent | MouseEvent;
|
|
95
|
+
}
|
|
96
|
+
export interface SelectInfo {
|
|
97
|
+
start: Dayjs;
|
|
98
|
+
end: Dayjs;
|
|
99
|
+
allDay: boolean;
|
|
100
|
+
}
|
|
101
|
+
export interface EventChangeInfo {
|
|
102
|
+
event: CalendarEvent;
|
|
103
|
+
oldEvent: CalendarEvent;
|
|
104
|
+
/** Call to undo the change in a controlled calendar. */
|
|
105
|
+
revert: () => void;
|
|
106
|
+
}
|
|
107
|
+
export interface EventContentArg {
|
|
108
|
+
event: CalendarEvent;
|
|
109
|
+
view: CalendarViewName;
|
|
110
|
+
timeText: string;
|
|
111
|
+
isStart: boolean;
|
|
112
|
+
isEnd: boolean;
|
|
113
|
+
isPast: boolean;
|
|
114
|
+
isFuture: boolean;
|
|
115
|
+
isToday: boolean;
|
|
116
|
+
}
|
|
117
|
+
/** Options shared by every view; the <Calendar> shell fills in defaults. */
|
|
118
|
+
export interface CalendarOptions {
|
|
119
|
+
locale?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Time zone dates are displayed and reported in: 'local' (default), 'UTC' or an IANA name ('Asia/Kolkata').
|
|
122
|
+
* Event strings with an offset/`Z` are converted; strings without one are wall-clock time in this zone.
|
|
123
|
+
*/
|
|
124
|
+
timeZone?: 'local' | 'UTC' | (string & {});
|
|
125
|
+
/** 0 = Sunday ... 6 = Saturday. Default 0. */
|
|
126
|
+
firstDay?: number;
|
|
127
|
+
weekends?: boolean;
|
|
128
|
+
weekNumbers?: boolean;
|
|
129
|
+
nowIndicator?: boolean;
|
|
130
|
+
businessHours?: boolean | BusinessHours;
|
|
131
|
+
selectable?: boolean;
|
|
132
|
+
editable?: boolean;
|
|
133
|
+
/** Max events per day cell before "+N more" (dayGrid). true = fit to cell height. */
|
|
134
|
+
dayMaxEvents?: boolean | number;
|
|
135
|
+
/** Minutes per time-grid slot row. Default 30. */
|
|
136
|
+
slotDuration?: number;
|
|
137
|
+
/** 'HH:mm' */
|
|
138
|
+
slotMinTime?: string;
|
|
139
|
+
slotMaxTime?: string;
|
|
140
|
+
/** Pixel height of one slot row in time-grid views. Default 28. */
|
|
141
|
+
slotHeight?: number;
|
|
142
|
+
/** Fixed height in px or CSS value; 'auto' grows with content. */
|
|
143
|
+
height?: number | string;
|
|
144
|
+
/** 'HH:mm' scroll target on mount in time-grid. Default 08:00. */
|
|
145
|
+
scrollTime?: string;
|
|
146
|
+
/** Hide events from other months in dayGrid month. */
|
|
147
|
+
showNonCurrentDates?: boolean;
|
|
148
|
+
eventTimeFormat?: string;
|
|
149
|
+
eventContent?: (arg: EventContentArg) => ReactNode;
|
|
150
|
+
eventClassNames?: (arg: EventContentArg) => string | string[];
|
|
151
|
+
dayCellClassNames?: (arg: {
|
|
152
|
+
date: Dayjs;
|
|
153
|
+
isToday: boolean;
|
|
154
|
+
isOther: boolean;
|
|
155
|
+
}) => string | string[];
|
|
156
|
+
noEventsContent?: ReactNode;
|
|
157
|
+
style?: CSSProperties;
|
|
158
|
+
className?: string;
|
|
159
|
+
}
|
|
160
|
+
export interface CalendarCallbacks {
|
|
161
|
+
onEventClick?: (info: EventClickInfo) => void;
|
|
162
|
+
onDateClick?: (info: DateClickInfo) => void;
|
|
163
|
+
onSelect?: (info: SelectInfo) => void;
|
|
164
|
+
/** Event moved by drag-and-drop (start/end already updated on `event`). */
|
|
165
|
+
onEventDrop?: (info: EventChangeInfo) => void;
|
|
166
|
+
/** Event resized (start/end already updated on `event`). */
|
|
167
|
+
onEventResize?: (info: EventChangeInfo) => void;
|
|
168
|
+
/** Visible date range changed (navigation or view change). */
|
|
169
|
+
onDatesSet?: (range: DateRange & {
|
|
170
|
+
view: CalendarViewName;
|
|
171
|
+
}) => void;
|
|
172
|
+
onNavLinkDayClick?: (date: Dayjs) => void;
|
|
173
|
+
/** A dynamic event source started (true) or all of them finished (false) loading. */
|
|
174
|
+
onLoading?: (isLoading: boolean) => void;
|
|
175
|
+
/** A function or JSON-feed source failed. Aborted (stale) requests are not reported. */
|
|
176
|
+
onEventsError?: (error: Error) => void;
|
|
177
|
+
}
|
|
178
|
+
/** What the shell passes to every view component. */
|
|
179
|
+
export interface CalendarViewProps extends CalendarOptions, CalendarCallbacks {
|
|
180
|
+
view: CalendarViewName;
|
|
181
|
+
/** Anchor date of the visible range. */
|
|
182
|
+
date: Dayjs;
|
|
183
|
+
events: CalendarEvent[];
|
|
184
|
+
dayjs: LocaleDayjs;
|
|
185
|
+
/** Called by views to switch view/date (nav links, "+N more", year month titles). */
|
|
186
|
+
goTo: (view: CalendarViewName, date: Dayjs) => void;
|
|
187
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CalendarEvent, DateRange, EventSource } from './types';
|
|
2
|
+
export interface UseEventSources {
|
|
3
|
+
events: CalendarEvent[];
|
|
4
|
+
loading: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Merge `events` + `eventSources` into one array. Arrays are used as is; function and feed sources are (re)fetched
|
|
8
|
+
* whenever the visible range changes, and a request left behind by navigation is aborted and ignored.
|
|
9
|
+
* Inline function sources do not refetch on re-render; feeds refetch when url/method change.
|
|
10
|
+
*/
|
|
11
|
+
export declare function useEventSources(events: EventSource | undefined, eventSources: EventSource[] | undefined, range: DateRange, onLoading?: (loading: boolean) => void, onError?: (error: Error) => void): UseEventSources;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
const isWrapped = (s) => typeof s === 'object' && !Array.isArray(s) && 'events' in s;
|
|
3
|
+
function normalize(src) {
|
|
4
|
+
if (isWrapped(src))
|
|
5
|
+
return { input: src.events, color: src.color, textColor: src.textColor };
|
|
6
|
+
const feed = !Array.isArray(src) && typeof src === 'object' ? src : undefined;
|
|
7
|
+
return { input: src, color: feed?.color, textColor: feed?.textColor };
|
|
8
|
+
}
|
|
9
|
+
const paint = (events, { color, textColor }) => color || textColor
|
|
10
|
+
? events.map(e => ({ ...e, color: e.color ?? color, textColor: e.textColor ?? textColor }))
|
|
11
|
+
: events;
|
|
12
|
+
const resolve = (v) => (typeof v === 'function' ? v() : v);
|
|
13
|
+
async function fetchFeed(feed, range, signal) {
|
|
14
|
+
const params = new URLSearchParams();
|
|
15
|
+
const all = { ...resolve(feed.params), ...resolve(feed.extraParams) };
|
|
16
|
+
for (const [k, v] of Object.entries(all))
|
|
17
|
+
params.set(k, String(v));
|
|
18
|
+
params.set(feed.startParam ?? 'start', range.start.toISOString());
|
|
19
|
+
params.set(feed.endParam ?? 'end', range.end.toISOString());
|
|
20
|
+
const post = feed.method === 'POST';
|
|
21
|
+
const url = post ? feed.url : `${feed.url}${feed.url.includes('?') ? '&' : '?'}${params}`;
|
|
22
|
+
const res = await fetch(url, {
|
|
23
|
+
method: feed.method ?? 'GET',
|
|
24
|
+
headers: { ...(post ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}), ...feed.headers },
|
|
25
|
+
body: post ? params : undefined,
|
|
26
|
+
signal,
|
|
27
|
+
});
|
|
28
|
+
if (!res.ok)
|
|
29
|
+
throw new Error(`Event feed ${feed.url} responded ${res.status}`);
|
|
30
|
+
return (await res.json());
|
|
31
|
+
}
|
|
32
|
+
const load = (src, range, signal) => Promise.resolve(typeof src.input === 'function'
|
|
33
|
+
? src.input(range, signal)
|
|
34
|
+
: fetchFeed(src.input, range, signal));
|
|
35
|
+
/**
|
|
36
|
+
* Merge `events` + `eventSources` into one array. Arrays are used as is; function and feed sources are (re)fetched
|
|
37
|
+
* whenever the visible range changes, and a request left behind by navigation is aborted and ignored.
|
|
38
|
+
* Inline function sources do not refetch on re-render; feeds refetch when url/method change.
|
|
39
|
+
*/
|
|
40
|
+
export function useEventSources(events, eventSources, range, onLoading, onError) {
|
|
41
|
+
const list = useMemo(() => (events === undefined ? [] : [events]).concat(eventSources ?? []).map(normalize), [events, eventSources]);
|
|
42
|
+
const latest = useRef({ list, onError });
|
|
43
|
+
latest.current = { list, onError };
|
|
44
|
+
// fetched results by source position; kept while a new range loads so the grid doesn't blink
|
|
45
|
+
const [fetched, setFetched] = useState({});
|
|
46
|
+
const [loading, setLoading] = useState(false);
|
|
47
|
+
const dynKey = list
|
|
48
|
+
.map(s => typeof s.input === 'function'
|
|
49
|
+
? 'fn'
|
|
50
|
+
: Array.isArray(s.input)
|
|
51
|
+
? ''
|
|
52
|
+
: JSON.stringify([s.input.url, s.input.method]))
|
|
53
|
+
.join('|');
|
|
54
|
+
const from = range.start.valueOf();
|
|
55
|
+
const to = range.end.valueOf();
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const dynamic = latest.current.list.flatMap((s, i) => (Array.isArray(s.input) ? [] : [{ s, i }]));
|
|
58
|
+
if (!dynamic.length) {
|
|
59
|
+
setLoading(false);
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
const ctl = new AbortController();
|
|
63
|
+
setLoading(true);
|
|
64
|
+
Promise.all(dynamic.map(async ({ s, i }) => {
|
|
65
|
+
try {
|
|
66
|
+
const events = await load(s, range, ctl.signal);
|
|
67
|
+
if (!ctl.signal.aborted)
|
|
68
|
+
setFetched(f => ({ ...f, [i]: events }));
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
if (!ctl.signal.aborted)
|
|
72
|
+
latest.current.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
73
|
+
}
|
|
74
|
+
})).then(() => {
|
|
75
|
+
if (!ctl.signal.aborted)
|
|
76
|
+
setLoading(false);
|
|
77
|
+
});
|
|
78
|
+
return () => ctl.abort();
|
|
79
|
+
}, [from, to, dynKey]);
|
|
80
|
+
const lastLoading = useRef(false);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (lastLoading.current !== loading) {
|
|
83
|
+
lastLoading.current = loading;
|
|
84
|
+
onLoading?.(loading);
|
|
85
|
+
}
|
|
86
|
+
}, [loading, onLoading]);
|
|
87
|
+
const merged = useMemo(() => list.flatMap((s, i) => paint(Array.isArray(s.input) ? s.input : (fetched[i] ?? []), s)), [list, fetched]);
|
|
88
|
+
return { events: merged, loading };
|
|
89
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { LocaleDayjs } from '../types';
|
|
2
|
+
import type { CalendarEvent, DateRange } from './types';
|
|
3
|
+
/** Expand recurring events for `range`. The (rrule-sized) engine loads on first use; until then recurring events are held back. */
|
|
4
|
+
export declare function useRecurrence(events: CalendarEvent[], range: DateRange, dayjs: LocaleDayjs): CalendarEvent[];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
/** Expand recurring events for `range`. The (rrule-sized) engine loads on first use; until then recurring events are held back. */
|
|
3
|
+
export function useRecurrence(events, range, dayjs) {
|
|
4
|
+
const needed = useMemo(() => events.some(e => e.rrule || e.recurrence), [events]);
|
|
5
|
+
const [engine, setEngine] = useState();
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if (needed && !engine)
|
|
8
|
+
import('./recurrence').then(setEngine);
|
|
9
|
+
}, [needed, engine]);
|
|
10
|
+
return useMemo(() => {
|
|
11
|
+
if (!needed)
|
|
12
|
+
return events;
|
|
13
|
+
return engine ? engine.expandRecurring(events, range, dayjs) : events.filter(e => !e.rrule && !e.recurrence);
|
|
14
|
+
}, [events, needed, engine, range.start.valueOf(), range.end.valueOf(), dayjs]);
|
|
15
|
+
}
|