@super-calendar/core 2.0.0
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/LICENSE +21 -0
- package/dist/index.d.mts +542 -0
- package/dist/index.d.ts +542 -0
- package/dist/index.js +794 -0
- package/dist/index.mjs +747 -0
- package/package.json +54 -0
- package/src/index.ts +15 -0
- package/src/presentation.ts +39 -0
- package/src/tokens.ts +73 -0
- package/src/types.ts +70 -0
- package/src/utils/dateRange.ts +163 -0
- package/src/utils/dates.ts +118 -0
- package/src/utils/drag.ts +79 -0
- package/src/utils/eventDisplay.ts +180 -0
- package/src/utils/layout.ts +193 -0
- package/src/utils/monthGrid.ts +144 -0
- package/src/utils/recurrence.ts +98 -0
- package/src/utils/timezone.ts +57 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { format } from "date-fns";
|
|
2
|
+
import type { CalendarMode } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimum event-box height (px) before the built-in renderer shows the time line
|
|
6
|
+
* on a narrow multi-column timed grid. Tied to the default theme's font sizes.
|
|
7
|
+
*/
|
|
8
|
+
export const MIN_BOX_HEIGHT_FOR_TIME = 56;
|
|
9
|
+
|
|
10
|
+
/** Hard-clip an overflowing title by default; opt into a trailing ellipsis. */
|
|
11
|
+
export function titleEllipsizeMode(ellipsizeTitle: boolean): "clip" | "tail" {
|
|
12
|
+
return ellipsizeTitle ? "tail" : "clip";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Screen-reader label for an event: its title followed by "all day" or its time
|
|
17
|
+
* range (which the grid otherwise only conveys visually). Empty title is dropped.
|
|
18
|
+
*/
|
|
19
|
+
export function eventAccessibilityLabel(args: {
|
|
20
|
+
title?: string;
|
|
21
|
+
isAllDay: boolean;
|
|
22
|
+
start: Date;
|
|
23
|
+
end: Date;
|
|
24
|
+
ampm: boolean;
|
|
25
|
+
/** Spoken text for an all-day event. Default "all day". */
|
|
26
|
+
allDayLabel?: string;
|
|
27
|
+
}): string {
|
|
28
|
+
const timeFormat = args.ampm ? "h:mm a" : "HH:mm";
|
|
29
|
+
const time = args.isAllDay
|
|
30
|
+
? (args.allDayLabel ?? "all day")
|
|
31
|
+
: `${format(args.start, timeFormat)} to ${format(args.end, timeFormat)}`;
|
|
32
|
+
return [args.title, time].filter(Boolean).join(", ");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Month cells and the all-day lane show a single clipped line; timed-grid titles
|
|
37
|
+
* (`undefined`) wrap to fill the box.
|
|
38
|
+
*/
|
|
39
|
+
export function titleNumberOfLines(mode: CalendarMode, isAllDay: boolean): number | undefined {
|
|
40
|
+
return mode === "month" || isAllDay ? 1 : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The secondary line under the title in the built-in renderer, or `null` when
|
|
45
|
+
* none should show. Timed events get their `start - end` range. An all-day event
|
|
46
|
+
* gets the literal "All day" in the schedule (which has no all-day lane to
|
|
47
|
+
* signal it positionally) and nothing on the day/week grid (the lane already
|
|
48
|
+
* does). Month cells and `showTime={false}` always return `null`.
|
|
49
|
+
*/
|
|
50
|
+
export function eventTimeLabel(args: {
|
|
51
|
+
mode: CalendarMode;
|
|
52
|
+
isAllDay: boolean;
|
|
53
|
+
start: Date;
|
|
54
|
+
end: Date;
|
|
55
|
+
ampm: boolean;
|
|
56
|
+
showTime: boolean;
|
|
57
|
+
/** Text for an all-day event in the schedule. Default "All day". */
|
|
58
|
+
allDayLabel?: string;
|
|
59
|
+
}): string | null {
|
|
60
|
+
if (!args.showTime || args.mode === "month") return null;
|
|
61
|
+
if (args.isAllDay) return args.mode === "schedule" ? (args.allDayLabel ?? "All day") : null;
|
|
62
|
+
const timeFormat = args.ampm ? "h:mm a" : "HH:mm";
|
|
63
|
+
return `${format(args.start, timeFormat)} - ${format(args.end, timeFormat)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The default hour-axis label shared by both renderers' time grids, so the gutter
|
|
68
|
+
* reads the same on each: 24-hour "HH:00" (e.g. "08:00"), or a compact 12-hour
|
|
69
|
+
* "h AM/PM" (e.g. "8 AM") when `ampm` is set. Exported so a custom hour renderer
|
|
70
|
+
* can reuse the same formatting.
|
|
71
|
+
*/
|
|
72
|
+
export function formatHour(hour: number, opts?: { ampm?: boolean }): string {
|
|
73
|
+
if (opts?.ampm) {
|
|
74
|
+
const period = hour < 12 ? "AM" : "PM";
|
|
75
|
+
const hour12 = hour % 12 === 0 ? 12 : hour % 12;
|
|
76
|
+
return `${hour12} ${period}`;
|
|
77
|
+
}
|
|
78
|
+
return `${String(hour).padStart(2, "0")}:00`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether the time line fits in the box. The wide `day` column and contexts with
|
|
83
|
+
* no live box height (e.g. schedule, where `boxHeightPx` is undefined) always
|
|
84
|
+
* show it; narrow multi-column modes only once the box is at least
|
|
85
|
+
* {@link MIN_BOX_HEIGHT_FOR_TIME} tall. Runs on the UI thread inside the event
|
|
86
|
+
* renderer's animated style.
|
|
87
|
+
*/
|
|
88
|
+
export function isTimeVisibleAtHeight(
|
|
89
|
+
boxHeightPx: number | undefined,
|
|
90
|
+
mode: CalendarMode,
|
|
91
|
+
): boolean {
|
|
92
|
+
"worklet";
|
|
93
|
+
if (boxHeightPx == null || mode === "day") return true;
|
|
94
|
+
return boxHeightPx >= MIN_BOX_HEIGHT_FOR_TIME;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Layout for the built-in timed-grid event chip at a given box height. */
|
|
98
|
+
export type EventChipLayout = {
|
|
99
|
+
/**
|
|
100
|
+
* Max whole title lines that fit in the box. `0` means "no clamp" (the box
|
|
101
|
+
* height is unknown, e.g. the schedule), so the title may wrap freely.
|
|
102
|
+
*/
|
|
103
|
+
titleMaxLines: number;
|
|
104
|
+
/** Whether the secondary time line still has room below the title. */
|
|
105
|
+
showTime: boolean;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Lay out the built-in timed-grid event chip for a box of `boxHeightPx`: how
|
|
110
|
+
* many whole title lines fit, and whether the time line still has room below
|
|
111
|
+
* them. The title is primary, so the title fills the box in whole lines (never a
|
|
112
|
+
* half-cropped line) and the time only shows once a full line is left over. Pass
|
|
113
|
+
* `titleLineHeightPx`/`timeLineHeightPx` matching the rendered line heights so
|
|
114
|
+
* the clamp lands on a line boundary.
|
|
115
|
+
*
|
|
116
|
+
* Worklet-safe, so the native renderer can drive the title's max-height on the UI
|
|
117
|
+
* thread as the grid zooms; the dom renderer calls it with its static box height.
|
|
118
|
+
* A `boxHeightPx` of `undefined` (the schedule has no live box height) returns
|
|
119
|
+
* `titleMaxLines: 0` (no clamp) with the unconditional time visibility.
|
|
120
|
+
*/
|
|
121
|
+
export function eventChipLayout(args: {
|
|
122
|
+
boxHeightPx: number | undefined;
|
|
123
|
+
mode: CalendarMode;
|
|
124
|
+
hasTime: boolean;
|
|
125
|
+
titleLineHeightPx: number;
|
|
126
|
+
timeLineHeightPx: number;
|
|
127
|
+
paddingYPx: number;
|
|
128
|
+
}): EventChipLayout {
|
|
129
|
+
"worklet";
|
|
130
|
+
const wantTime = args.hasTime && isTimeVisibleAtHeight(args.boxHeightPx, args.mode);
|
|
131
|
+
if (args.boxHeightPx == null || args.titleLineHeightPx <= 0) {
|
|
132
|
+
return { titleMaxLines: 0, showTime: wantTime };
|
|
133
|
+
}
|
|
134
|
+
const inner = args.boxHeightPx - args.paddingYPx * 2;
|
|
135
|
+
// Reserve the time line first, then give the title every whole line that fits
|
|
136
|
+
// in what remains (always at least one line).
|
|
137
|
+
const titleMaxLines = Math.max(
|
|
138
|
+
1,
|
|
139
|
+
Math.floor((inner - (wantTime ? args.timeLineHeightPx : 0)) / args.titleLineHeightPx),
|
|
140
|
+
);
|
|
141
|
+
// Only keep the time if a full line is still free under the clamped title, so a
|
|
142
|
+
// short box drops the time rather than slicing it in half.
|
|
143
|
+
const showTime =
|
|
144
|
+
wantTime && inner - titleMaxLines * args.titleLineHeightPx >= args.timeLineHeightPx;
|
|
145
|
+
return { titleMaxLines, showTime };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** How many month-cell chips fit in the available height. */
|
|
149
|
+
export type MonthEventCapacity = {
|
|
150
|
+
/** Count when every event fits, with no overflow label. */
|
|
151
|
+
full: number;
|
|
152
|
+
/** Count that leaves room for the "+N more" label. */
|
|
153
|
+
withMore: number;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Derive how many event chips fit in a month cell from the measured space.
|
|
158
|
+
* `chipRowHeightPx` is one chip plus its gap; `moreRowHeightPx` is the overflow
|
|
159
|
+
* label plus its gap. Both counts are clamped to >= 0.
|
|
160
|
+
*/
|
|
161
|
+
export function monthEventCapacity(
|
|
162
|
+
availableHeightPx: number,
|
|
163
|
+
chipRowHeightPx: number,
|
|
164
|
+
moreRowHeightPx: number,
|
|
165
|
+
): MonthEventCapacity {
|
|
166
|
+
if (chipRowHeightPx <= 0) return { full: 0, withMore: 0 };
|
|
167
|
+
return {
|
|
168
|
+
full: Math.max(0, Math.floor(availableHeightPx / chipRowHeightPx)),
|
|
169
|
+
withMore: Math.max(0, Math.floor((availableHeightPx - moreRowHeightPx) / chipRowHeightPx)),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Chips to show for a day: all of them when they fit, otherwise `withMore` (at
|
|
175
|
+
* least one) so the rest collapse into a "+N more" label.
|
|
176
|
+
*/
|
|
177
|
+
export function monthVisibleCount(total: number, capacity: MonthEventCapacity): number {
|
|
178
|
+
if (total <= capacity.full) return total;
|
|
179
|
+
return Math.max(1, capacity.withMore);
|
|
180
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { addDays, differenceInMinutes, max as maxDate, min as minDate, startOfDay } from "date-fns";
|
|
2
|
+
import type { BusinessHours, CalendarEvent } from "../types";
|
|
3
|
+
|
|
4
|
+
const MINUTES_PER_HOUR = 60;
|
|
5
|
+
// Minimum duration (in hours) a positioned event is given, so a zero/negative
|
|
6
|
+
// span still occupies a sliver rather than collapsing to nothing.
|
|
7
|
+
const MIN_DURATION_HOURS = 0.25;
|
|
8
|
+
|
|
9
|
+
export type PositionedEvent<T> = {
|
|
10
|
+
event: CalendarEvent<T>;
|
|
11
|
+
/** Hours from midnight to the event's segment start on this day (fractional). */
|
|
12
|
+
startHours: number;
|
|
13
|
+
/** Segment duration in hours on this day (clamped to a small minimum). */
|
|
14
|
+
durationHours: number;
|
|
15
|
+
/** Zero-based column index within its overlap cluster. */
|
|
16
|
+
column: number;
|
|
17
|
+
/** Total columns in this event's overlap cluster. */
|
|
18
|
+
columns: number;
|
|
19
|
+
/** True when the segment is clipped because the event continues before/after this day. */
|
|
20
|
+
continuesBefore: boolean;
|
|
21
|
+
continuesAfter: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type Segment<T> = {
|
|
25
|
+
event: CalendarEvent<T>;
|
|
26
|
+
start: number;
|
|
27
|
+
end: number;
|
|
28
|
+
continuesBefore: boolean;
|
|
29
|
+
continuesAfter: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lay out a single day's events: events that overlap in time are split into
|
|
34
|
+
* side-by-side columns. Multi-day events are clipped to the portion that falls
|
|
35
|
+
* on `day` (e.g. a 23:00→01:00 event renders 23:00–24:00 on the start day and
|
|
36
|
+
* 00:00–01:00 on the next). Pure — safe to call per render, never per frame.
|
|
37
|
+
*/
|
|
38
|
+
export function layoutDayEvents<T>(events: CalendarEvent<T>[], day: Date): PositionedEvent<T>[] {
|
|
39
|
+
const dayStart = startOfDay(day);
|
|
40
|
+
const nextDayStart = addDays(dayStart, 1);
|
|
41
|
+
|
|
42
|
+
const segments: Segment<T>[] = events
|
|
43
|
+
// All-day events live in the lane, not the timed columns.
|
|
44
|
+
.filter((event) => !isAllDayEvent(event))
|
|
45
|
+
// Overlaps this day if it starts before the day ends and ends after it begins.
|
|
46
|
+
.filter((event) => event.start < nextDayStart && event.end > dayStart)
|
|
47
|
+
.map((event) => {
|
|
48
|
+
const segStart = maxDate([event.start, dayStart]);
|
|
49
|
+
const segEnd = minDate([event.end, nextDayStart]);
|
|
50
|
+
return {
|
|
51
|
+
event,
|
|
52
|
+
start: differenceInMinutes(segStart, dayStart) / MINUTES_PER_HOUR,
|
|
53
|
+
end: differenceInMinutes(segEnd, dayStart) / MINUTES_PER_HOUR,
|
|
54
|
+
continuesBefore: event.start < dayStart,
|
|
55
|
+
continuesAfter: event.end > nextDayStart,
|
|
56
|
+
};
|
|
57
|
+
})
|
|
58
|
+
.sort((a, b) => a.start - b.start);
|
|
59
|
+
|
|
60
|
+
const positioned: PositionedEvent<T>[] = [];
|
|
61
|
+
let cluster: Segment<T>[] = [];
|
|
62
|
+
let clusterEnd = Number.NEGATIVE_INFINITY;
|
|
63
|
+
|
|
64
|
+
const flushCluster = () => {
|
|
65
|
+
const columnEnds: number[] = [];
|
|
66
|
+
const columnOf = new Map<Segment<T>, number>();
|
|
67
|
+
for (const seg of cluster) {
|
|
68
|
+
let column = columnEnds.findIndex((end) => end <= seg.start);
|
|
69
|
+
if (column === -1) {
|
|
70
|
+
column = columnEnds.length;
|
|
71
|
+
columnEnds.push(seg.end);
|
|
72
|
+
} else {
|
|
73
|
+
columnEnds[column] = seg.end;
|
|
74
|
+
}
|
|
75
|
+
columnOf.set(seg, column);
|
|
76
|
+
}
|
|
77
|
+
for (const seg of cluster) {
|
|
78
|
+
positioned.push({
|
|
79
|
+
event: seg.event,
|
|
80
|
+
startHours: seg.start,
|
|
81
|
+
durationHours: Math.max(seg.end - seg.start, MIN_DURATION_HOURS),
|
|
82
|
+
column: columnOf.get(seg) ?? 0,
|
|
83
|
+
columns: columnEnds.length,
|
|
84
|
+
continuesBefore: seg.continuesBefore,
|
|
85
|
+
continuesAfter: seg.continuesAfter,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
cluster = [];
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
for (const seg of segments) {
|
|
92
|
+
if (cluster.length > 0 && seg.start >= clusterEnd) flushCluster();
|
|
93
|
+
cluster.push(seg);
|
|
94
|
+
clusterEnd = Math.max(clusterEnd, seg.end);
|
|
95
|
+
}
|
|
96
|
+
if (cluster.length > 0) flushCluster();
|
|
97
|
+
|
|
98
|
+
return positioned;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const atMidnight = (date: Date): boolean =>
|
|
102
|
+
date.getHours() === 0 &&
|
|
103
|
+
date.getMinutes() === 0 &&
|
|
104
|
+
date.getSeconds() === 0 &&
|
|
105
|
+
date.getMilliseconds() === 0;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Whether an event belongs in the all-day lane. An explicit `allDay` flag wins;
|
|
109
|
+
* otherwise it's inferred when the event spans whole days (both `start` and
|
|
110
|
+
* `end` land on midnight, e.g. an iCal-style all-day event). Pure.
|
|
111
|
+
*/
|
|
112
|
+
export function isAllDayEvent<T>(event: CalendarEvent<T>): boolean {
|
|
113
|
+
if (typeof event.allDay === "boolean") return event.allDay;
|
|
114
|
+
return event.end > event.start && atMidnight(event.start) && atMidnight(event.end);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The `startOfDay` ISO keys of every calendar day an event touches (inclusive).
|
|
119
|
+
* An event ending exactly at midnight does not count the following day. Used to
|
|
120
|
+
* index events by day for the month grid. Pure.
|
|
121
|
+
*/
|
|
122
|
+
export function eventDayKeys<T>(event: CalendarEvent<T>): string[] {
|
|
123
|
+
const first = startOfDay(event.start);
|
|
124
|
+
// The last instant the event occupies; an end of exactly midnight belongs to
|
|
125
|
+
// the previous day.
|
|
126
|
+
const lastInstant = event.end > event.start ? new Date(event.end.getTime() - 1) : event.start;
|
|
127
|
+
const last = startOfDay(lastInstant);
|
|
128
|
+
|
|
129
|
+
const keys: string[] = [];
|
|
130
|
+
for (let cursor = first; cursor <= last; cursor = addDays(cursor, 1)) {
|
|
131
|
+
keys.push(cursor.toISOString());
|
|
132
|
+
}
|
|
133
|
+
return keys;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Index events by the `startOfDay` ISO key of every day they touch (via
|
|
138
|
+
* {@link eventDayKeys}), so a month grid can look up a day's events with
|
|
139
|
+
* `startOfDay(date).toISOString()`. Built once and shared across month cells.
|
|
140
|
+
*/
|
|
141
|
+
export function groupEventsByDay<T>(
|
|
142
|
+
events: readonly CalendarEvent<T>[],
|
|
143
|
+
): Map<string, CalendarEvent<T>[]> {
|
|
144
|
+
const map = new Map<string, CalendarEvent<T>[]>();
|
|
145
|
+
for (const event of events) {
|
|
146
|
+
for (const key of eventDayKeys(event)) {
|
|
147
|
+
const list = map.get(key);
|
|
148
|
+
if (list) list.push(event);
|
|
149
|
+
else map.set(key, [event]);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return map;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Order a day's events for the month and list views: all-day events come first
|
|
157
|
+
* (they head the day regardless of their start time), then timed events by start.
|
|
158
|
+
* Shared by both renderers so the order is identical. Use as an `Array.sort`
|
|
159
|
+
* comparator.
|
|
160
|
+
*/
|
|
161
|
+
export function compareDayEvents<T>(a: CalendarEvent<T>, b: CalendarEvent<T>): number {
|
|
162
|
+
const aAllDay = isAllDayEvent(a);
|
|
163
|
+
const bAllDay = isAllDayEvent(b);
|
|
164
|
+
if (aAllDay !== bAllDay) return aAllDay ? -1 : 1;
|
|
165
|
+
return a.start.getTime() - b.start.getTime();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The closed hour-spans of a day to shade on the time grid, given a
|
|
170
|
+
* `businessHours` callback and the visible `[minHour, maxHour]` window: the spans
|
|
171
|
+
* before open and after close (clamped to the window), the whole window when the
|
|
172
|
+
* day is closed (`null`) or the open hours are inverted/empty, or none when the
|
|
173
|
+
* callback returns `undefined`. Shared by both renderers so shading stays
|
|
174
|
+
* identical. Co-located with `groupEventsByDay`; both feed the grid layout.
|
|
175
|
+
*/
|
|
176
|
+
export function closedHourBands(
|
|
177
|
+
day: Date,
|
|
178
|
+
businessHours: BusinessHours | undefined,
|
|
179
|
+
minHour = 0,
|
|
180
|
+
maxHour = 24,
|
|
181
|
+
): { start: number; end: number }[] {
|
|
182
|
+
const open = businessHours?.(day);
|
|
183
|
+
if (open === undefined) return [];
|
|
184
|
+
if (open === null) return [{ start: minHour, end: maxHour }];
|
|
185
|
+
const start = Math.max(minHour, Math.min(maxHour, open.start));
|
|
186
|
+
const end = Math.max(minHour, Math.min(maxHour, open.end));
|
|
187
|
+
// Inverted or empty open hours mean nothing is open: shade the whole window.
|
|
188
|
+
if (start >= end) return [{ start: minHour, end: maxHour }];
|
|
189
|
+
const bands: { start: number; end: number }[] = [];
|
|
190
|
+
if (start > minHour) bands.push({ start: minHour, end: start });
|
|
191
|
+
if (end < maxHour) bands.push({ start: end, end: maxHour });
|
|
192
|
+
return bands;
|
|
193
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { format, type Locale, isSameMonth } from "date-fns";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import type { WeekStartsOn } from "../types";
|
|
4
|
+
import { type DateRange, type DateSelectionConstraints, daySelectionState } from "./dateRange";
|
|
5
|
+
import { buildMonthWeeks, getIsToday, isWeekend } from "./dates";
|
|
6
|
+
|
|
7
|
+
/** A single day in the grid, with all the state a custom cell needs to render. */
|
|
8
|
+
export interface MonthGridDay {
|
|
9
|
+
date: Date;
|
|
10
|
+
/** Stable `yyyy-MM-dd` id, handy as a React key. */
|
|
11
|
+
id: string;
|
|
12
|
+
/** Day-of-month, e.g. "1". */
|
|
13
|
+
label: string;
|
|
14
|
+
isCurrentMonth: boolean;
|
|
15
|
+
isToday: boolean;
|
|
16
|
+
isWeekend: boolean;
|
|
17
|
+
isDisabled: boolean;
|
|
18
|
+
isSelected: boolean;
|
|
19
|
+
isRangeStart: boolean;
|
|
20
|
+
isRangeEnd: boolean;
|
|
21
|
+
/** Inside a complete range (endpoints included). */
|
|
22
|
+
isInRange: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One week row. */
|
|
26
|
+
export interface MonthGridWeek {
|
|
27
|
+
id: string;
|
|
28
|
+
days: MonthGridDay[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A weekday header cell (e.g. "Mon"). */
|
|
32
|
+
export interface MonthGridWeekday {
|
|
33
|
+
date: Date;
|
|
34
|
+
label: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MonthGrid {
|
|
38
|
+
weeks: MonthGridWeek[];
|
|
39
|
+
weekdays: MonthGridWeekday[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UseMonthGridOptions extends DateSelectionConstraints {
|
|
43
|
+
/** First day of the week. Sunday = 0 (default) … Saturday = 6. */
|
|
44
|
+
weekStartsOn?: WeekStartsOn;
|
|
45
|
+
/** Always return six week rows for a fixed-height grid. Default false. */
|
|
46
|
+
showSixWeeks?: boolean;
|
|
47
|
+
/** Reverse each week's day order (right-to-left). Default false. */
|
|
48
|
+
isRTL?: boolean;
|
|
49
|
+
/** Selected discrete days (single/multiple). */
|
|
50
|
+
selectedDates?: Date[];
|
|
51
|
+
/** Selected span. */
|
|
52
|
+
selectedRange?: DateRange;
|
|
53
|
+
/** A date-fns locale for the weekday labels. */
|
|
54
|
+
locale?: Locale;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Pure month-grid builder: the weeks and weekday headers for `month`, each day
|
|
59
|
+
* annotated with selection/disabled/today state. Use this when you need the
|
|
60
|
+
* data outside React; inside a component prefer {@link useMonthGrid}.
|
|
61
|
+
*/
|
|
62
|
+
export function buildMonthGrid(month: Date, options: UseMonthGridOptions = {}): MonthGrid {
|
|
63
|
+
const {
|
|
64
|
+
weekStartsOn = 0,
|
|
65
|
+
showSixWeeks = false,
|
|
66
|
+
isRTL = false,
|
|
67
|
+
selectedDates,
|
|
68
|
+
selectedRange,
|
|
69
|
+
minDate,
|
|
70
|
+
maxDate,
|
|
71
|
+
isDateDisabled,
|
|
72
|
+
locale,
|
|
73
|
+
} = options;
|
|
74
|
+
|
|
75
|
+
const rows = buildMonthWeeks(month, weekStartsOn, { showSixWeeks, isRTL });
|
|
76
|
+
|
|
77
|
+
const weeks: MonthGridWeek[] = rows.map((days) => ({
|
|
78
|
+
id: days[0].toISOString(),
|
|
79
|
+
days: days.map(
|
|
80
|
+
(date): MonthGridDay => ({
|
|
81
|
+
date,
|
|
82
|
+
id: format(date, "yyyy-MM-dd"),
|
|
83
|
+
label: format(date, "d"),
|
|
84
|
+
isCurrentMonth: isSameMonth(date, month),
|
|
85
|
+
isToday: getIsToday(date),
|
|
86
|
+
isWeekend: isWeekend(date),
|
|
87
|
+
// Shared with MonthView, so the headless grid matches the built-in view.
|
|
88
|
+
...daySelectionState(
|
|
89
|
+
date,
|
|
90
|
+
{ selectedDates, selectedRange },
|
|
91
|
+
{ minDate, maxDate, isDateDisabled },
|
|
92
|
+
),
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
// Weekday labels depend only on the first row's dates (already ordered).
|
|
98
|
+
const weekdays: MonthGridWeekday[] = rows[0].map((date) => ({
|
|
99
|
+
date,
|
|
100
|
+
label: format(date, "EEE", { locale }),
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
return { weeks, weekdays };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Headless month-grid hook. Returns the weeks and weekday headers for `month`,
|
|
108
|
+
* each day annotated with selection/disabled/today state, so you can render a
|
|
109
|
+
* fully custom calendar without reimplementing the date maths.
|
|
110
|
+
*
|
|
111
|
+
* ```tsx
|
|
112
|
+
* const { weeks, weekdays } = useMonthGrid(month, { selectedRange: range });
|
|
113
|
+
* // map weekdays -> header cells, weeks -> rows, days -> your own <DayCell />
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
export function useMonthGrid(month: Date, options: UseMonthGridOptions = {}): MonthGrid {
|
|
117
|
+
const {
|
|
118
|
+
weekStartsOn,
|
|
119
|
+
showSixWeeks,
|
|
120
|
+
isRTL,
|
|
121
|
+
selectedDates,
|
|
122
|
+
selectedRange,
|
|
123
|
+
minDate,
|
|
124
|
+
maxDate,
|
|
125
|
+
isDateDisabled,
|
|
126
|
+
locale,
|
|
127
|
+
} = options;
|
|
128
|
+
return useMemo(
|
|
129
|
+
() => buildMonthGrid(month, options),
|
|
130
|
+
// Deps are the destructured option fields, not the (unstable) options object.
|
|
131
|
+
[
|
|
132
|
+
month,
|
|
133
|
+
weekStartsOn,
|
|
134
|
+
showSixWeeks,
|
|
135
|
+
isRTL,
|
|
136
|
+
selectedDates,
|
|
137
|
+
selectedRange,
|
|
138
|
+
minDate,
|
|
139
|
+
maxDate,
|
|
140
|
+
isDateDisabled,
|
|
141
|
+
locale,
|
|
142
|
+
],
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { addDays, addMonths, addWeeks, addYears, startOfWeek } from "date-fns";
|
|
2
|
+
import type { CalendarEvent, RecurrenceFrequency, RecurrenceRule } from "../types";
|
|
3
|
+
|
|
4
|
+
const STEP: Record<RecurrenceFrequency, (date: Date, amount: number) => Date> = {
|
|
5
|
+
daily: addDays,
|
|
6
|
+
weekly: addWeeks,
|
|
7
|
+
monthly: addMonths,
|
|
8
|
+
yearly: addYears,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Runaway guard. Generous enough for realistic ranges (e.g. ~13 years of a daily
|
|
12
|
+
// event); set `count`/`until`, or query a tighter range, for anything larger.
|
|
13
|
+
const MAX_OCCURRENCES = 5000;
|
|
14
|
+
|
|
15
|
+
// Copy `source`'s time of day onto `date`.
|
|
16
|
+
function withTimeOf(date: Date, source: Date): Date {
|
|
17
|
+
const next = new Date(date);
|
|
18
|
+
next.setHours(
|
|
19
|
+
source.getHours(),
|
|
20
|
+
source.getMinutes(),
|
|
21
|
+
source.getSeconds(),
|
|
22
|
+
source.getMilliseconds(),
|
|
23
|
+
);
|
|
24
|
+
return next;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Occurrence start dates from `event.start` forward, in chronological order, up
|
|
28
|
+
// to `rangeEnd` (and the rule's own `count`/`until`).
|
|
29
|
+
function* occurrenceStarts(start: Date, rule: RecurrenceRule, rangeEnd: Date): Generator<Date> {
|
|
30
|
+
const interval = Math.max(1, Math.trunc(rule.interval ?? 1));
|
|
31
|
+
let produced = 0;
|
|
32
|
+
const within = (date: Date) =>
|
|
33
|
+
date.getTime() <= rangeEnd.getTime() &&
|
|
34
|
+
(rule.until == null || date.getTime() <= rule.until.getTime()) &&
|
|
35
|
+
(rule.count == null || produced < rule.count) &&
|
|
36
|
+
produced < MAX_OCCURRENCES;
|
|
37
|
+
|
|
38
|
+
if (rule.freq === "weekly" && rule.weekdays?.length) {
|
|
39
|
+
const weekdays = [...new Set(rule.weekdays)].sort((a, b) => a - b);
|
|
40
|
+
let weekStart = startOfWeek(start, { weekStartsOn: 0 });
|
|
41
|
+
while (true) {
|
|
42
|
+
let advanced = false;
|
|
43
|
+
for (const weekday of weekdays) {
|
|
44
|
+
const date = withTimeOf(addDays(weekStart, weekday), start);
|
|
45
|
+
if (date.getTime() < start.getTime()) continue; // before the first occurrence
|
|
46
|
+
if (!within(date)) return;
|
|
47
|
+
produced += 1;
|
|
48
|
+
advanced = true;
|
|
49
|
+
yield date;
|
|
50
|
+
}
|
|
51
|
+
const nextWeek = addWeeks(weekStart, interval);
|
|
52
|
+
// Guard against a week that yielded nothing yet hasn't reached the range.
|
|
53
|
+
if (!advanced && nextWeek.getTime() > rangeEnd.getTime()) return;
|
|
54
|
+
weekStart = nextWeek;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let cursor = start;
|
|
59
|
+
while (within(cursor)) {
|
|
60
|
+
produced += 1;
|
|
61
|
+
yield cursor;
|
|
62
|
+
cursor = STEP[rule.freq](cursor, interval);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function instanceAt<T>(event: CalendarEvent<T>, start: Date, durationMs: number): CalendarEvent<T> {
|
|
67
|
+
const instance = { ...event, start, end: new Date(start.getTime() + durationMs) };
|
|
68
|
+
// Occurrences aren't themselves recurring.
|
|
69
|
+
delete (instance as { recurrence?: unknown }).recurrence;
|
|
70
|
+
return instance;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Materialise recurring events into concrete occurrences overlapping
|
|
75
|
+
* `[rangeStart, rangeEnd]`. Non-recurring events pass through untouched, so the
|
|
76
|
+
* result is ready to hand to `<Calendar events={...} />`. Each occurrence keeps
|
|
77
|
+
* the original event's duration and fields (minus `recurrence`).
|
|
78
|
+
*/
|
|
79
|
+
export function expandRecurringEvents<T>(
|
|
80
|
+
events: CalendarEvent<T>[],
|
|
81
|
+
rangeStart: Date,
|
|
82
|
+
rangeEnd: Date,
|
|
83
|
+
): CalendarEvent<T>[] {
|
|
84
|
+
const out: CalendarEvent<T>[] = [];
|
|
85
|
+
for (const event of events) {
|
|
86
|
+
if (!event.recurrence) {
|
|
87
|
+
out.push(event);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const durationMs = event.end.getTime() - event.start.getTime();
|
|
91
|
+
for (const start of occurrenceStarts(event.start, event.recurrence, rangeEnd)) {
|
|
92
|
+
// Skip occurrences that end before the range opens, but keep iterating.
|
|
93
|
+
if (start.getTime() + durationMs < rangeStart.getTime()) continue;
|
|
94
|
+
out.push(instanceAt(event, start, durationMs));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CalendarEvent } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reinterpret an instant as its wall-clock time in `timeZone`, returned as a
|
|
5
|
+
* device-local `Date` whose fields (hours, minutes, …) read back as that zone's
|
|
6
|
+
* clock. The calendar lays events out from `getHours()`/`getMinutes()`, so
|
|
7
|
+
* passing zoned dates makes it render in `timeZone` regardless of the device.
|
|
8
|
+
*
|
|
9
|
+
* DST-correct via `Intl` (available on modern React Native Hermes/JSC and the
|
|
10
|
+
* web). The result is for display/layout only; it no longer points at the
|
|
11
|
+
* original UTC instant, so don't round-trip it back to a real time.
|
|
12
|
+
*/
|
|
13
|
+
export function toZonedTime(date: Date, timeZone: string): Date {
|
|
14
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
15
|
+
timeZone,
|
|
16
|
+
hourCycle: "h23",
|
|
17
|
+
year: "numeric",
|
|
18
|
+
month: "2-digit",
|
|
19
|
+
day: "2-digit",
|
|
20
|
+
hour: "2-digit",
|
|
21
|
+
minute: "2-digit",
|
|
22
|
+
second: "2-digit",
|
|
23
|
+
}).formatToParts(date);
|
|
24
|
+
|
|
25
|
+
const field = (type: Intl.DateTimeFormatPartTypes): number => {
|
|
26
|
+
const part = parts.find((p) => p.type === type);
|
|
27
|
+
return part ? Number(part.value) : 0;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// `h23` can report midnight as 24; normalise to 0.
|
|
31
|
+
const hour = field("hour") % 24;
|
|
32
|
+
return new Date(
|
|
33
|
+
field("year"),
|
|
34
|
+
field("month") - 1,
|
|
35
|
+
field("day"),
|
|
36
|
+
hour,
|
|
37
|
+
field("minute"),
|
|
38
|
+
field("second"),
|
|
39
|
+
date.getMilliseconds(),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Map every event's `start`/`end` through {@link toZonedTime} so the calendar
|
|
45
|
+
* displays them in `timeZone`. Other fields are preserved. Memoize the result
|
|
46
|
+
* (e.g. with `useMemo`) since it allocates new dates.
|
|
47
|
+
*/
|
|
48
|
+
export function eventsInTimeZone<T>(
|
|
49
|
+
events: CalendarEvent<T>[],
|
|
50
|
+
timeZone: string,
|
|
51
|
+
): CalendarEvent<T>[] {
|
|
52
|
+
return events.map((event) => ({
|
|
53
|
+
...event,
|
|
54
|
+
start: toZonedTime(event.start, timeZone),
|
|
55
|
+
end: toZonedTime(event.end, timeZone),
|
|
56
|
+
}));
|
|
57
|
+
}
|