@lotics/ui 46.14.1 → 47.1.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/AGENTS.md +20 -0
- package/MIGRATION.md +58 -0
- package/docs/catalog.md +72 -13
- package/docs/composition.md +94 -6
- package/docs/reviewing.md +56 -5
- package/docs/templates.md +8 -2
- package/examples/tpl_calendar.tsx +22 -29
- package/examples/tpl_item_list.tsx +7 -2
- package/package.json +1 -1
- package/src/accordion.tsx +11 -5
- package/src/bar_chart.tsx +3 -1
- package/src/board.tsx +1 -2
- package/src/breakdown.tsx +3 -1
- package/src/calendar/agenda_view.tsx +136 -0
- package/src/calendar/calendar_toolbar.tsx +84 -0
- package/src/calendar/calendar_view.tsx +196 -103
- package/src/calendar/context.ts +47 -0
- package/src/calendar/dates.ts +36 -6
- package/src/calendar/event_chip.tsx +141 -0
- package/src/calendar/index.ts +31 -8
- package/src/calendar/layout.ts +113 -11
- package/src/calendar/month_view.tsx +194 -150
- package/src/calendar/repeat.ts +174 -0
- package/src/calendar/time_grid_view.tsx +182 -202
- package/src/calendar/types.ts +37 -11
- package/src/charge_lines.tsx +24 -5
- package/src/control_surface.ts +28 -0
- package/src/deadline.ts +10 -0
- package/src/file_row.tsx +26 -3
- package/src/finding.tsx +1 -1
- package/src/form_text_input.tsx +9 -2
- package/src/gantt/gantt_view.tsx +212 -119
- package/src/gantt/index.ts +2 -2
- package/src/gantt/scale.ts +38 -2
- package/src/gantt/types.ts +34 -7
- package/src/inline_slot.tsx +16 -2
- package/src/inline_static.tsx +18 -4
- package/src/legend_item.tsx +14 -1
- package/src/locale.tsx +30 -0
- package/src/matrix.tsx +19 -5
- package/src/member_chip.tsx +18 -1
- package/src/menu_button.tsx +12 -2
- package/src/option_list.tsx +11 -1
- package/src/progress_bar.tsx +14 -1
- package/src/record_summary.tsx +5 -0
- package/src/stacked_bar_chart.tsx +4 -1
- package/src/table_fit.ts +18 -1
- package/src/thumbnail_stack.tsx +18 -3
- package/src/use_option_list.ts +13 -1
|
@@ -1,78 +1,66 @@
|
|
|
1
|
-
import { useMemo,
|
|
2
|
-
import { View, StyleSheet, type
|
|
1
|
+
import { useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
import { View, StyleSheet, type LayoutChangeEvent } from "react-native";
|
|
3
3
|
import { Text } from "../text";
|
|
4
4
|
import { colors } from "../colors";
|
|
5
5
|
import { FocusRingPressable } from "../focus_ring_pressable";
|
|
6
|
+
import { Popover, PopoverTrigger, PopoverContent } from "../popover";
|
|
7
|
+
import { useCalendar } from "./context";
|
|
8
|
+
import { EventChip } from "./event_chip";
|
|
9
|
+
import { compareEvents, fitLanes, isBanner, packEventLanes } from "./layout";
|
|
10
|
+
import { addDays, dayDiff, dayHeading, daysInView, isSameMonth, isToday, startOfDay, weekdayShort } from "./dates";
|
|
11
|
+
import type { CalendarEvent } from "./types";
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
onPress?: () => void;
|
|
9
|
-
accessibilityLabel?: string;
|
|
10
|
-
style: StyleProp<ViewStyle>;
|
|
11
|
-
pressableRef?: Ref<View>;
|
|
12
|
-
children: ReactNode;
|
|
13
|
-
}) {
|
|
14
|
-
return (
|
|
15
|
-
<FocusRingPressable
|
|
16
|
-
ref={props.pressableRef}
|
|
17
|
-
onPress={props.onPress}
|
|
18
|
-
accessibilityRole={props.onPress ? "button" : undefined}
|
|
19
|
-
accessibilityLabel={props.accessibilityLabel}
|
|
20
|
-
style={props.style}
|
|
21
|
-
>
|
|
22
|
-
{props.children}
|
|
23
|
-
</FocusRingPressable>
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
import { usePointerDrag } from "../use_pointer_drag";
|
|
27
|
-
import { packEventLanes } from "./layout";
|
|
28
|
-
import { addDays, dayDiff, daysInView, isSameMonth, isToday, startOfDay, weekdayShort } from "./dates";
|
|
29
|
-
import type { CalendarEvent, Weekday } from "./types";
|
|
30
|
-
|
|
31
|
-
const LANE_H = 19;
|
|
13
|
+
const LANE_H = 20;
|
|
32
14
|
const LANE_GAP = 2;
|
|
33
|
-
const
|
|
15
|
+
const DATE_ROW_H = 26;
|
|
16
|
+
const OVERFLOW_CHIP_H = 17;
|
|
17
|
+
const WEEKDAY_HEADER_H = 28;
|
|
34
18
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The height below which a month grid stops being able to say anything.
|
|
21
|
+
*
|
|
22
|
+
* Six rows that can hold the date, one event and the overflow chip. Under this
|
|
23
|
+
* every row fits the chip alone, and the grid degenerates into a heat map of
|
|
24
|
+
* "+3 more" with not one event named — which is a worse answer than the agenda,
|
|
25
|
+
* so the root falls back to it on height exactly as it does on width.
|
|
26
|
+
*/
|
|
27
|
+
export const MIN_MONTH_HEIGHT = 6 * (DATE_ROW_H + LANE_H + OVERFLOW_CHIP_H) + WEEKDAY_HEADER_H;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The month grid.
|
|
31
|
+
*
|
|
32
|
+
* Two things it decides for you, because getting either wrong is invisible until
|
|
33
|
+
* a real month arrives:
|
|
34
|
+
*
|
|
35
|
+
* **How many week rows.** As many as the month needs (4–6), not a fixed 42 days.
|
|
36
|
+
* A constant six spends a whole row on greyed-out next-month dates in most
|
|
37
|
+
* months.
|
|
38
|
+
*
|
|
39
|
+
* **How many event lanes.** Measured, not constant. The row is `flex: 1`, so its
|
|
40
|
+
* height is whatever the calendar's height divided by the week count happens to
|
|
41
|
+
* be — a fixed cap either wastes a tall row or draws lanes straight through a
|
|
42
|
+
* short one's neighbours. We measure and fit, and give one lane back to the
|
|
43
|
+
* "+N more" chip when there is overflow.
|
|
44
|
+
*/
|
|
45
|
+
export function CalendarMonth() {
|
|
46
|
+
const {
|
|
47
|
+
events, date, setDate, setView, weekStartsOn, locale, labels, now, onEventPress, onSlotPress, renderEvent,
|
|
48
|
+
} = useCalendar();
|
|
48
49
|
|
|
49
|
-
export function MonthView<T = unknown>(props: MonthViewProps<T>) {
|
|
50
|
-
const { date, events, weekStartsOn = 1, locale, moreLabel = (n) => `+${n} more`, onEventPress, onDayPress, onEventDrop } = props;
|
|
51
50
|
const days = useMemo(() => daysInView("month", date, weekStartsOn), [date, weekStartsOn]);
|
|
52
|
-
const weeks = useMemo(
|
|
51
|
+
const weeks = useMemo(
|
|
52
|
+
() => Array.from({ length: days.length / 7 }, (_, w) => days.slice(w * 7, w * 7 + 7)),
|
|
53
|
+
[days],
|
|
54
|
+
);
|
|
53
55
|
const weekdayLabels = useMemo(() => days.slice(0, 7).map((d) => weekdayShort(d, locale)), [days, locale]);
|
|
54
|
-
const now = new Date();
|
|
55
56
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
for (const [w, el] of weekNodes.current) {
|
|
64
|
-
const r = el.getBoundingClientRect();
|
|
65
|
-
if (r.width > 0 && pointer.y >= r.top && pointer.y <= r.bottom) {
|
|
66
|
-
const col = Math.min(6, Math.max(0, Math.floor((pointer.x - r.left) / (r.width / 7))));
|
|
67
|
-
const target = days[w * 7 + col];
|
|
68
|
-
if (!target) return;
|
|
69
|
-
const shift = dayDiff(startOfDay(ev.start), target);
|
|
70
|
-
if (shift === 0) return;
|
|
71
|
-
onEventDrop(ev, addDays(ev.start, shift), ev.end ? addDays(ev.end, shift) : null);
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
});
|
|
57
|
+
// One measurement for every row: the rows are equal-height siblings, so a
|
|
58
|
+
// per-row measurement would be six identical numbers and six extra renders.
|
|
59
|
+
const [laneAreaHeight, setLaneAreaHeight] = useState(0);
|
|
60
|
+
const onRowLayout = (e: LayoutChangeEvent) => {
|
|
61
|
+
const next = Math.round(e.nativeEvent.layout.height - DATE_ROW_H);
|
|
62
|
+
setLaneAreaHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev));
|
|
63
|
+
};
|
|
76
64
|
|
|
77
65
|
return (
|
|
78
66
|
<View style={styles.root}>
|
|
@@ -85,115 +73,97 @@ export function MonthView<T = unknown>(props: MonthViewProps<T>) {
|
|
|
85
73
|
</View>
|
|
86
74
|
|
|
87
75
|
{weeks.map((weekDays, w) => {
|
|
88
|
-
const { bars } = packEventLanes(events, weekDays[0], 7);
|
|
89
|
-
const
|
|
90
|
-
const
|
|
76
|
+
const { bars, lanes } = packEventLanes(events, weekDays[0], 7);
|
|
77
|
+
const { visibleLanes, overflows } = fitLanes(laneAreaHeight, LANE_H, LANE_GAP, lanes, OVERFLOW_CHIP_H);
|
|
78
|
+
const visible = bars.filter((b) => b.lane < visibleLanes);
|
|
79
|
+
|
|
80
|
+
// What each day still has to show. Counted per DAY, not per bar: a
|
|
81
|
+
// five-day bar that did not fit is one hidden thing on each of the five
|
|
82
|
+
// days it covers, and a reader looking at Wednesday wants Wednesday's
|
|
83
|
+
// number.
|
|
84
|
+
const hidden = Array.from({ length: 7 }, () => 0);
|
|
91
85
|
for (const b of bars) {
|
|
92
|
-
if (b.lane
|
|
93
|
-
|
|
94
|
-
}
|
|
86
|
+
if (b.lane < visibleLanes) continue;
|
|
87
|
+
for (let c = b.startCol; c < b.startCol + b.span && c < 7; c++) hidden[c]++;
|
|
95
88
|
}
|
|
96
89
|
|
|
97
90
|
return (
|
|
98
|
-
<View
|
|
99
|
-
key={w}
|
|
100
|
-
style={styles.weekRow}
|
|
101
|
-
ref={(node) => {
|
|
102
|
-
const el = node as unknown as HTMLElement | null;
|
|
103
|
-
if (el) weekNodes.current.set(w, el);
|
|
104
|
-
else weekNodes.current.delete(w);
|
|
105
|
-
}}
|
|
106
|
-
>
|
|
107
|
-
{/* Date numbers */}
|
|
91
|
+
<View key={w} style={styles.weekRow} onLayout={w === 0 ? onRowLayout : undefined}>
|
|
108
92
|
<View style={styles.dateRow}>
|
|
109
93
|
{weekDays.map((day) => {
|
|
110
94
|
const inMonth = isSameMonth(day, date);
|
|
111
95
|
const today = isToday(day, now);
|
|
112
96
|
return (
|
|
113
|
-
<
|
|
97
|
+
<FocusRingPressable
|
|
114
98
|
key={day.toISOString()}
|
|
115
|
-
onPress={
|
|
99
|
+
onPress={onSlotPress ? () => onSlotPress(day, addDays(day, 1)) : undefined}
|
|
100
|
+
accessibilityRole={onSlotPress ? "button" : undefined}
|
|
101
|
+
accessibilityLabel={dayHeading(day, locale)}
|
|
116
102
|
style={styles.dateCell}
|
|
117
103
|
>
|
|
118
|
-
<View style={[styles.dateBadge, today &&
|
|
104
|
+
<View style={[styles.dateBadge, today && styles.dateBadgeToday]}>
|
|
119
105
|
<Text
|
|
120
106
|
size="xs"
|
|
121
107
|
weight={today ? "semibold" : "regular"}
|
|
122
|
-
style={
|
|
108
|
+
style={today ? styles.dateToday : inMonth ? styles.dateIn : styles.dateOut}
|
|
123
109
|
>
|
|
124
110
|
{day.getDate()}
|
|
125
111
|
</Text>
|
|
126
112
|
</View>
|
|
127
|
-
</
|
|
113
|
+
</FocusRingPressable>
|
|
128
114
|
);
|
|
129
115
|
})}
|
|
130
116
|
</View>
|
|
131
117
|
|
|
132
|
-
{/* Event lanes */}
|
|
133
118
|
<View style={styles.laneArea}>
|
|
134
119
|
{weekDays.map((_, i) =>
|
|
135
120
|
i === 0 ? null : (
|
|
136
|
-
<View
|
|
137
|
-
key={i}
|
|
138
|
-
style={{ position: "absolute", left: `${(i / 7) * 100}%`, top: -2, bottom: 0, borderLeftWidth: 1, borderLeftColor: colors.zinc[100] }}
|
|
139
|
-
/>
|
|
121
|
+
<View key={i} style={[styles.colRule, { left: `${(i / 7) * 100}%` }]} />
|
|
140
122
|
),
|
|
141
123
|
)}
|
|
142
|
-
{visible.map((bar) =>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
{
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
})}
|
|
186
|
-
{weekDays.map((day, col) =>
|
|
187
|
-
overflow[col] > 0 ? (
|
|
188
|
-
<MonthPressable
|
|
189
|
-
key={`o${col}`}
|
|
190
|
-
onPress={onDayPress ? () => onDayPress(day) : undefined}
|
|
191
|
-
style={{ position: "absolute", top: MAX_LANES * (LANE_H + LANE_GAP), left: `${(col / 7) * 100}%`, width: `${100 / 7}%`, paddingHorizontal: 6 }}
|
|
192
|
-
>
|
|
193
|
-
<Text size="xs" color="muted">{moreLabel(overflow[col])}</Text>
|
|
194
|
-
</MonthPressable>
|
|
195
|
-
) : null,
|
|
196
|
-
)}
|
|
124
|
+
{visible.map((bar) => (
|
|
125
|
+
<EventChip
|
|
126
|
+
key={bar.event.id}
|
|
127
|
+
event={bar.event}
|
|
128
|
+
variant={isBanner(bar.event) ? "banner" : "dot"}
|
|
129
|
+
showTime={!isBanner(bar.event)}
|
|
130
|
+
height={LANE_H}
|
|
131
|
+
locale={locale}
|
|
132
|
+
render={renderEvent}
|
|
133
|
+
onPress={onEventPress ? () => onEventPress(bar.event.id) : undefined}
|
|
134
|
+
style={{
|
|
135
|
+
position: "absolute",
|
|
136
|
+
top: bar.lane * (LANE_H + LANE_GAP),
|
|
137
|
+
height: LANE_H,
|
|
138
|
+
left: `${(bar.startCol / 7) * 100}%`,
|
|
139
|
+
width: `${(bar.span / 7) * 100}%`,
|
|
140
|
+
paddingHorizontal: 2,
|
|
141
|
+
}}
|
|
142
|
+
/>
|
|
143
|
+
))}
|
|
144
|
+
{overflows
|
|
145
|
+
? weekDays.map((day, col) =>
|
|
146
|
+
hidden[col] > 0 ? (
|
|
147
|
+
<DayOverflow
|
|
148
|
+
key={`o${col}`}
|
|
149
|
+
day={day}
|
|
150
|
+
col={col}
|
|
151
|
+
top={visibleLanes * (LANE_H + LANE_GAP)}
|
|
152
|
+
events={events}
|
|
153
|
+
label={labels.more(hidden[col])}
|
|
154
|
+
heading={dayHeading(day, locale)}
|
|
155
|
+
locale={locale}
|
|
156
|
+
renderEvent={renderEvent}
|
|
157
|
+
onEventPress={onEventPress}
|
|
158
|
+
onOpenDay={() => {
|
|
159
|
+
setDate(day);
|
|
160
|
+
setView("day");
|
|
161
|
+
}}
|
|
162
|
+
openDayLabel={labels.day}
|
|
163
|
+
/>
|
|
164
|
+
) : null,
|
|
165
|
+
)
|
|
166
|
+
: null}
|
|
197
167
|
</View>
|
|
198
168
|
</View>
|
|
199
169
|
);
|
|
@@ -202,13 +172,87 @@ export function MonthView<T = unknown>(props: MonthViewProps<T>) {
|
|
|
202
172
|
);
|
|
203
173
|
}
|
|
204
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The "+N more" chip and what it opens.
|
|
177
|
+
*
|
|
178
|
+
* A POPOVER of that day's events, not a jump to day view. Switching views to
|
|
179
|
+
* read three more titles costs the reader the month they were looking at, and
|
|
180
|
+
* getting back means finding the month button and then the date again.
|
|
181
|
+
*/
|
|
182
|
+
function DayOverflow(props: {
|
|
183
|
+
day: Date;
|
|
184
|
+
col: number;
|
|
185
|
+
top: number;
|
|
186
|
+
events: CalendarEvent<unknown>[];
|
|
187
|
+
label: string;
|
|
188
|
+
heading: string;
|
|
189
|
+
locale?: string;
|
|
190
|
+
renderEvent?: (event: CalendarEvent<unknown>) => ReactNode;
|
|
191
|
+
onEventPress?: (id: string) => void;
|
|
192
|
+
onOpenDay: () => void;
|
|
193
|
+
openDayLabel: string;
|
|
194
|
+
}) {
|
|
195
|
+
const dayEvents = useMemo(
|
|
196
|
+
() =>
|
|
197
|
+
props.events
|
|
198
|
+
.filter((e) => {
|
|
199
|
+
const from = dayDiff(startOfDay(e.start), props.day);
|
|
200
|
+
const to = dayDiff(props.day, startOfDay(e.end ?? e.start));
|
|
201
|
+
return from >= 0 && to >= 0;
|
|
202
|
+
})
|
|
203
|
+
.sort(compareEvents),
|
|
204
|
+
[props.events, props.day],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<View style={[styles.overflowSlot, { left: `${(props.col / 7) * 100}%`, top: props.top }]}>
|
|
209
|
+
<Popover side="bottom" align="start">
|
|
210
|
+
<PopoverTrigger>
|
|
211
|
+
<FocusRingPressable accessibilityRole="button" accessibilityLabel={`${props.heading}, ${props.label}`} style={styles.overflowPress}>
|
|
212
|
+
<Text size="xs" color="muted" numberOfLines={1}>{props.label}</Text>
|
|
213
|
+
</FocusRingPressable>
|
|
214
|
+
</PopoverTrigger>
|
|
215
|
+
<PopoverContent width={260}>
|
|
216
|
+
<View style={styles.dayPanel}>
|
|
217
|
+
<FocusRingPressable accessibilityRole="button" onPress={props.onOpenDay} accessibilityLabel={`${props.heading} — ${props.openDayLabel}`}>
|
|
218
|
+
<Text size="sm" weight="semibold">{props.heading}</Text>
|
|
219
|
+
</FocusRingPressable>
|
|
220
|
+
{dayEvents.map((e) => (
|
|
221
|
+
<EventChip
|
|
222
|
+
key={e.id}
|
|
223
|
+
event={e}
|
|
224
|
+
variant={isBanner(e) ? "banner" : "dot"}
|
|
225
|
+
showTime={!isBanner(e)}
|
|
226
|
+
height={LANE_H}
|
|
227
|
+
locale={props.locale}
|
|
228
|
+
render={props.renderEvent}
|
|
229
|
+
onPress={props.onEventPress ? () => props.onEventPress?.(e.id) : undefined}
|
|
230
|
+
style={{ height: LANE_H }}
|
|
231
|
+
/>
|
|
232
|
+
))}
|
|
233
|
+
</View>
|
|
234
|
+
</PopoverContent>
|
|
235
|
+
</Popover>
|
|
236
|
+
</View>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
205
240
|
const styles = StyleSheet.create({
|
|
206
241
|
root: { flex: 1, backgroundColor: colors.white },
|
|
207
|
-
|
|
242
|
+
// The weekday strip is a LABEL row, not a boundary — space sets it off.
|
|
243
|
+
weekdayHeader: { flexDirection: "row", paddingTop: 2, paddingBottom: 8 },
|
|
208
244
|
weekdayCell: { flex: 1, alignItems: "center" },
|
|
209
|
-
weekRow: { flex: 1,
|
|
210
|
-
dateRow: { flexDirection: "row", paddingTop: 4 },
|
|
245
|
+
weekRow: { flex: 1, borderTopWidth: 1, borderTopColor: colors.zinc[100], minHeight: DATE_ROW_H + LANE_H },
|
|
246
|
+
dateRow: { flexDirection: "row", height: DATE_ROW_H, paddingTop: 4 },
|
|
211
247
|
dateCell: { flex: 1, alignItems: "center" },
|
|
212
248
|
dateBadge: { minWidth: 22, height: 22, borderRadius: 11, alignItems: "center", justifyContent: "center", paddingHorizontal: 4 },
|
|
213
|
-
|
|
249
|
+
dateBadgeToday: { backgroundColor: colors.teal[600] },
|
|
250
|
+
dateToday: { color: colors.white },
|
|
251
|
+
dateIn: { color: colors.zinc[800] },
|
|
252
|
+
dateOut: { color: colors.zinc[400] },
|
|
253
|
+
laneArea: { flex: 1, position: "relative" },
|
|
254
|
+
colRule: { position: "absolute", top: -DATE_ROW_H, bottom: 0, borderLeftWidth: 1, borderLeftColor: colors.zinc[100] },
|
|
255
|
+
overflowSlot: { position: "absolute", width: `${100 / 7}%`, paddingHorizontal: 4 },
|
|
256
|
+
overflowPress: { paddingHorizontal: 2, height: OVERFLOW_CHIP_H, justifyContent: "center" },
|
|
257
|
+
dayPanel: { gap: 4, paddingVertical: 4 },
|
|
214
258
|
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { addDays, dayDiff, startOfDay } from "./dates";
|
|
2
|
+
import type { CalendarEvent, Weekday } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A repeating event, in the shape business scheduling actually uses.
|
|
6
|
+
*
|
|
7
|
+
* **This is deliberately NOT RFC 5545.** A full RRULE (`BYSETPOS`, ordinal
|
|
8
|
+
* `BYDAY`, `EXDATE`, the DST rules) is either a real dependency or several
|
|
9
|
+
* hundred lines of spec, in a calendar whose stated virtue is that it adds no
|
|
10
|
+
* date library to an app. What it buys over this is the long tail — "the last
|
|
11
|
+
* working Friday of the quarter" — and what it costs is paid by every app that
|
|
12
|
+
* only ever needed "every weekday at 06:00".
|
|
13
|
+
*
|
|
14
|
+
* So: a timetable, a standup, a monthly close. When something genuinely needs
|
|
15
|
+
* the tail, the answer is a recurrence FIELD TYPE in the platform and a parser
|
|
16
|
+
* behind it — not widening this.
|
|
17
|
+
*/
|
|
18
|
+
export interface RepeatRule {
|
|
19
|
+
every: "day" | "week" | "month";
|
|
20
|
+
/** Every N days/weeks/months. Default 1; values below 1 are treated as 1. */
|
|
21
|
+
interval?: number;
|
|
22
|
+
/** `week` only — which weekdays. Defaults to the start date's own weekday. */
|
|
23
|
+
on?: Weekday[];
|
|
24
|
+
/** Inclusive last day the event may occur on. */
|
|
25
|
+
until?: Date | null;
|
|
26
|
+
/** Stop after this many occurrences, counting the first. */
|
|
27
|
+
count?: number | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A safety stop, so a malformed rule cannot spin. No real range asks for more. */
|
|
31
|
+
const MAX_OCCURRENCES = 1000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The id an occurrence carries: the source event's id, then the day it falls on.
|
|
35
|
+
*
|
|
36
|
+
* Occurrences need distinct ids — React keys them, and the layout dedupes on
|
|
37
|
+
* them — but a press has to reach the ROW the series came from. Encoding the
|
|
38
|
+
* source in the id is what lets {@link sourceEventId} take it back off, so no
|
|
39
|
+
* caller has to keep a side table.
|
|
40
|
+
*/
|
|
41
|
+
export function occurrenceId(sourceId: string, day: Date): string {
|
|
42
|
+
const m = String(day.getMonth() + 1).padStart(2, "0");
|
|
43
|
+
const d = String(day.getDate()).padStart(2, "0");
|
|
44
|
+
return `${sourceId}@${day.getFullYear()}-${m}-${d}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The id of the event an occurrence came from — its own id, if it is not one. */
|
|
48
|
+
export function sourceEventId(id: string): string {
|
|
49
|
+
const at = id.lastIndexOf("@");
|
|
50
|
+
return at === -1 ? id : id.slice(0, at);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Expand every repeating event in `events` into the occurrences that fall in
|
|
55
|
+
* [rangeStart, rangeEnd]. Events without a `repeat` pass through untouched.
|
|
56
|
+
*
|
|
57
|
+
* Bounded by the range, which is why the calendar calls it per view rather than
|
|
58
|
+
* asking the app to expand a year up front: navigating a month at a time over a
|
|
59
|
+
* daily timetable would otherwise materialise thousands of events nobody looks
|
|
60
|
+
* at.
|
|
61
|
+
*/
|
|
62
|
+
export function expandRepeats<T>(
|
|
63
|
+
events: CalendarEvent<T>[],
|
|
64
|
+
rangeStart: Date,
|
|
65
|
+
rangeEnd: Date,
|
|
66
|
+
): CalendarEvent<T>[] {
|
|
67
|
+
const out: CalendarEvent<T>[] = [];
|
|
68
|
+
for (const event of events) {
|
|
69
|
+
if (!event.repeat) {
|
|
70
|
+
out.push(event);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const day of occurrenceDays(event.start, event.repeat, rangeStart, rangeEnd)) {
|
|
74
|
+
out.push(shiftTo(event, day));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The days a rule fires on inside a range.
|
|
82
|
+
*
|
|
83
|
+
* Walks from the series start rather than from the range, because `count` and
|
|
84
|
+
* `interval` are both defined relative to the FIRST occurrence — a rule that
|
|
85
|
+
* starts counting at whatever range the reader happens to be looking at would
|
|
86
|
+
* put "every other Tuesday" on a different Tuesday in March than in April.
|
|
87
|
+
*/
|
|
88
|
+
export function occurrenceDays(
|
|
89
|
+
seriesStart: Date,
|
|
90
|
+
rule: RepeatRule,
|
|
91
|
+
rangeStart: Date,
|
|
92
|
+
rangeEnd: Date,
|
|
93
|
+
): Date[] {
|
|
94
|
+
const interval = Math.max(1, Math.floor(rule.interval ?? 1));
|
|
95
|
+
const from = startOfDay(seriesStart);
|
|
96
|
+
const hardEnd = rule.until ? startOfDay(rule.until) : null;
|
|
97
|
+
const days: Date[] = [];
|
|
98
|
+
let produced = 0;
|
|
99
|
+
|
|
100
|
+
const emit = (day: Date): "stop" | "go" => {
|
|
101
|
+
if (hardEnd && day > hardEnd) return "stop";
|
|
102
|
+
produced += 1;
|
|
103
|
+
if (rule.count != null && produced > rule.count) return "stop";
|
|
104
|
+
if (day >= startOfDay(rangeStart) && day <= startOfDay(rangeEnd)) days.push(day);
|
|
105
|
+
// Past the range with no reason to keep counting: only `count` needs the
|
|
106
|
+
// walk to continue, and it has been satisfied above.
|
|
107
|
+
if (day > startOfDay(rangeEnd)) return "stop";
|
|
108
|
+
return produced >= MAX_OCCURRENCES ? "stop" : "go";
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
if (rule.every === "day") {
|
|
112
|
+
for (let cursor = from; ; cursor = addDays(cursor, interval)) {
|
|
113
|
+
if (emit(cursor) === "stop") break;
|
|
114
|
+
}
|
|
115
|
+
return days;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (rule.every === "week") {
|
|
119
|
+
// Which weekdays, in the week's own order, so occurrences come out sorted.
|
|
120
|
+
const weekdays = (rule.on?.length ? [...new Set(rule.on)] : [from.getDay() as Weekday]).sort(
|
|
121
|
+
(a, b) => a - b,
|
|
122
|
+
);
|
|
123
|
+
// Anchor on the series start's own week so `interval` counts whole weeks.
|
|
124
|
+
const anchor = addDays(from, -(((from.getDay() - weekdays[0] + 7) % 7)));
|
|
125
|
+
for (let week = anchor; ; week = addDays(week, 7 * interval)) {
|
|
126
|
+
let stopped = false;
|
|
127
|
+
for (const wd of weekdays) {
|
|
128
|
+
const day = addDays(week, (wd - week.getDay() + 7) % 7);
|
|
129
|
+
// A weekday earlier in the anchor week than the series start is not an
|
|
130
|
+
// occurrence — the series has not begun yet.
|
|
131
|
+
if (day < from) continue;
|
|
132
|
+
if (emit(day) === "stop") {
|
|
133
|
+
stopped = true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (stopped) break;
|
|
138
|
+
}
|
|
139
|
+
return days;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// month — the same day-of-month each time. A month too short SKIPS rather
|
|
143
|
+
// than clamping: "the 31st" does not happen in February, and sliding it to
|
|
144
|
+
// the 28th invents an occurrence the reader never scheduled.
|
|
145
|
+
const dom = from.getDate();
|
|
146
|
+
for (let step = 0; ; step += interval) {
|
|
147
|
+
const probe = new Date(from.getFullYear(), from.getMonth() + step, 1);
|
|
148
|
+
const lastDay = new Date(probe.getFullYear(), probe.getMonth() + 1, 0).getDate();
|
|
149
|
+
if (dom > lastDay) {
|
|
150
|
+
// Not an occurrence, but the walk must still know when to give up.
|
|
151
|
+
if (probe > startOfDay(rangeEnd)) break;
|
|
152
|
+
if (hardEnd && probe > hardEnd) break;
|
|
153
|
+
if (step / interval >= MAX_OCCURRENCES) break;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const day = new Date(probe.getFullYear(), probe.getMonth(), dom);
|
|
157
|
+
if (emit(day) === "stop") break;
|
|
158
|
+
}
|
|
159
|
+
return days;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** One occurrence: the same event, moved to `day`, keeping its duration. */
|
|
163
|
+
function shiftTo<T>(event: CalendarEvent<T>, day: Date): CalendarEvent<T> {
|
|
164
|
+
const offset = dayDiff(startOfDay(event.start), day);
|
|
165
|
+
if (offset === 0) return { ...event, id: occurrenceId(event.id, day), repeat: undefined };
|
|
166
|
+
const start = addDays(event.start, offset);
|
|
167
|
+
start.setHours(event.start.getHours(), event.start.getMinutes(), 0, 0);
|
|
168
|
+
let end: Date | null = null;
|
|
169
|
+
if (event.end) {
|
|
170
|
+
end = addDays(event.end, offset);
|
|
171
|
+
end.setHours(event.end.getHours(), event.end.getMinutes(), 0, 0);
|
|
172
|
+
}
|
|
173
|
+
return { ...event, id: occurrenceId(event.id, day), start, end, repeat: undefined };
|
|
174
|
+
}
|