@svgrid/grid 2.1.22 → 2.1.23
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 +9 -2
- package/dist/DockNodeView.svelte +306 -0
- package/dist/DockNodeView.svelte.d.ts +7 -0
- package/dist/DockPopoutHost.svelte +17 -0
- package/dist/DockPopoutHost.svelte.d.ts +14 -0
- package/dist/GridMenus.svelte +83 -3
- package/dist/SvAutoComplete.svelte +5 -3
- package/dist/SvCarousel.svelte +20 -42
- package/dist/SvColorInput.svelte +4 -3
- package/dist/SvColorInput.svelte.d.ts +1 -1
- package/dist/SvComboBox.svelte +7 -3
- package/dist/SvCommand.svelte +28 -85
- package/dist/SvCountryInput.svelte +1 -1
- package/dist/SvDateRangeInput.svelte +1 -1
- package/dist/SvDateTimePicker.svelte +9 -3
- package/dist/SvDateTimePicker.svelte.d.ts +3 -0
- package/dist/SvDockLayout.svelte +244 -0
- package/dist/SvDockLayout.svelte.d.ts +77 -0
- package/dist/SvDockManager.svelte +922 -0
- package/dist/SvDockManager.svelte.d.ts +106 -0
- package/dist/SvDrawer.svelte +69 -47
- package/dist/SvDrawer.svelte.d.ts +4 -0
- package/dist/SvDropDownList.svelte +85 -13
- package/dist/SvDropDownList.svelte.d.ts +3 -0
- package/dist/SvField.svelte +21 -4
- package/dist/SvField.svelte.d.ts +1 -0
- package/dist/SvForm.svelte +201 -91
- package/dist/SvForm.svelte.d.ts +15 -22
- package/dist/SvGrid.controller.svelte.d.ts +32 -1
- package/dist/SvGrid.controller.svelte.js +156 -27
- package/dist/SvGrid.css +193 -5
- package/dist/SvGrid.svelte +357 -12
- package/dist/SvGrid.types.d.ts +452 -1
- package/dist/SvGridDropdown.svelte +8 -1
- package/dist/SvGridSelect.svelte +1 -0
- package/dist/SvListBox.svelte +36 -14
- package/dist/SvMaskedInput.svelte +4 -3
- package/dist/SvMaskedInput.svelte.d.ts +1 -1
- package/dist/SvMenu.svelte +1 -1
- package/dist/SvMenuList.svelte +23 -71
- package/dist/SvMenuList.svelte.d.ts +2 -13
- package/dist/SvModal.svelte +12 -29
- package/dist/SvMultiSelect.svelte +1 -0
- package/dist/SvNumberInput.svelte +4 -3
- package/dist/SvNumberInput.svelte.d.ts +1 -1
- package/dist/SvPagination.svelte +18 -18
- package/dist/SvPasswordInput.svelte +3 -2
- package/dist/SvPasswordInput.svelte.d.ts +1 -1
- package/dist/SvPhoneInput.svelte +3 -2
- package/dist/SvPhoneInput.svelte.d.ts +1 -1
- package/dist/SvPopover.svelte +1 -1
- package/dist/SvStepper.svelte +6 -9
- package/dist/SvTagsInput.svelte +3 -2
- package/dist/SvTagsInput.svelte.d.ts +1 -1
- package/dist/SvTooltip.svelte +12 -23
- package/dist/SvTour.svelte +32 -4
- package/dist/SvTreeSelect.svelte +1 -0
- package/dist/build-api.js +76 -54
- package/dist/cdn/svgrid.js +10907 -6444
- package/dist/cdn/svgrid.svelte-external.js +9622 -5475
- package/dist/columns.js +6 -0
- package/dist/conditional-formatting.d.ts +67 -3
- package/dist/conditional-formatting.js +124 -17
- package/dist/createCarousel.svelte.d.ts +72 -0
- package/dist/createCarousel.svelte.js +80 -0
- package/dist/createCommand.svelte.d.ts +62 -0
- package/dist/createCommand.svelte.js +109 -0
- package/dist/createForm.svelte.d.ts +62 -0
- package/dist/createForm.svelte.js +374 -0
- package/dist/createMenu.svelte.d.ts +88 -0
- package/dist/createMenu.svelte.js +153 -0
- package/dist/createOverlay.svelte.d.ts +38 -0
- package/dist/createOverlay.svelte.js +68 -0
- package/dist/createPagination.d.ts +74 -0
- package/dist/createPagination.js +71 -0
- package/dist/createPopoverSelect.svelte.js +6 -12
- package/dist/createStepper.d.ts +44 -0
- package/dist/createStepper.js +26 -0
- package/dist/createTooltip.svelte.d.ts +32 -0
- package/dist/createTooltip.svelte.js +61 -0
- package/dist/datetime/timezone.d.ts +36 -0
- package/dist/datetime/timezone.js +110 -0
- package/dist/dock-context.d.ts +52 -0
- package/dist/dock-context.js +1 -0
- package/dist/dock-manager-model.d.ts +136 -0
- package/dist/dock-manager-model.js +514 -0
- package/dist/dock-model.d.ts +105 -0
- package/dist/dock-model.js +288 -0
- package/dist/dock-popout.d.ts +30 -0
- package/dist/dock-popout.js +81 -0
- package/dist/editor-contract.d.ts +2 -0
- package/dist/filter-operators.d.ts +6 -0
- package/dist/filter-operators.js +35 -2
- package/dist/filtering/excel-filters.d.ts +18 -1
- package/dist/filtering/excel-filters.js +56 -0
- package/dist/form-field.d.ts +73 -0
- package/dist/form-field.js +15 -0
- package/dist/index.d.ts +25 -5
- package/dist/index.js +40 -4
- package/dist/list-nav.d.ts +18 -0
- package/dist/list-nav.js +30 -0
- package/dist/list-option.d.ts +2 -0
- package/dist/menu-item.d.ts +20 -0
- package/dist/menu-item.js +1 -0
- package/dist/menus.d.ts +5 -0
- package/dist/menus.js +68 -16
- package/dist/panel-resize.d.ts +25 -0
- package/dist/panel-resize.js +40 -0
- package/dist/popover.d.ts +29 -3
- package/dist/popover.js +22 -6
- package/dist/recurrence.d.ts +33 -7
- package/dist/recurrence.js +151 -13
- package/dist/row-drag.d.ts +8 -0
- package/dist/row-drag.js +11 -0
- package/dist/scheduler-ical.d.ts +30 -0
- package/dist/scheduler-ical.js +180 -0
- package/dist/scheduler-model.d.ts +269 -0
- package/dist/scheduler-model.js +602 -0
- package/dist/scheduler-view.svelte.d.ts +22 -0
- package/dist/scheduler-view.svelte.js +13 -0
- package/dist/spreadsheet.js +20 -12
- package/dist/ui-app.types.d.ts +3 -0
- package/package.json +5 -1
- package/src/DockNodeView.svelte +306 -0
- package/src/DockPopoutHost.svelte +17 -0
- package/src/GridMenus.svelte +83 -3
- package/src/SvAutoComplete.svelte +5 -3
- package/src/SvCarousel.svelte +20 -42
- package/src/SvCarousel.test.ts +68 -0
- package/src/SvColorInput.svelte +4 -3
- package/src/SvComboBox.svelte +7 -3
- package/src/SvCommand.svelte +28 -85
- package/src/SvCommand.test.ts +78 -0
- package/src/SvCountryInput.svelte +1 -1
- package/src/SvDateRangeInput.svelte +1 -1
- package/src/SvDateTimePicker.svelte +9 -3
- package/src/SvDockLayout.svelte +244 -0
- package/src/SvDockManager.svelte +922 -0
- package/src/SvDrawer.svelte +69 -47
- package/src/SvDropDownList.svelte +85 -13
- package/src/SvField.svelte +21 -4
- package/src/SvForm.svelte +201 -91
- package/src/SvForm.test.ts +299 -0
- package/src/SvGrid.controller.svelte.ts +152 -28
- package/src/SvGrid.css +193 -5
- package/src/SvGrid.svelte +357 -12
- package/src/SvGrid.types.ts +463 -1
- package/src/SvGridDropdown.svelte +8 -1
- package/src/SvGridSelect.svelte +1 -0
- package/src/SvListBox.svelte +36 -14
- package/src/SvMaskedInput.svelte +4 -3
- package/src/SvMenu.svelte +1 -1
- package/src/SvMenu.test.ts +24 -0
- package/src/SvMenuList.svelte +23 -71
- package/src/SvModal.svelte +12 -29
- package/src/SvModal.test.ts +82 -0
- package/src/SvMultiSelect.svelte +1 -0
- package/src/SvNumberInput.svelte +4 -3
- package/src/SvPagination.svelte +18 -18
- package/src/SvPasswordInput.svelte +3 -2
- package/src/SvPhoneInput.svelte +3 -2
- package/src/SvPopover.svelte +1 -1
- package/src/SvStepper.svelte +6 -9
- package/src/SvTagsInput.svelte +3 -2
- package/src/SvTooltip.svelte +12 -23
- package/src/SvTooltip.test.ts +73 -0
- package/src/SvTour.svelte +32 -4
- package/src/SvTreeSelect.svelte +1 -0
- package/src/build-api.ts +74 -51
- package/src/columns.ts +5 -0
- package/src/conditional-formatting.test.ts +104 -0
- package/src/conditional-formatting.ts +176 -19
- package/src/createCarousel.svelte.ts +104 -0
- package/src/createCommand.svelte.ts +125 -0
- package/src/createForm.svelte.ts +333 -0
- package/src/createMenu.svelte.ts +202 -0
- package/src/createOverlay.svelte.ts +101 -0
- package/src/createPagination.test.ts +65 -0
- package/src/createPagination.ts +84 -0
- package/src/createPopoverSelect.svelte.ts +5 -9
- package/src/createStepper.test.ts +62 -0
- package/src/createStepper.ts +61 -0
- package/src/createTooltip.svelte.ts +86 -0
- package/src/datetime/timezone.test.ts +69 -0
- package/src/datetime/timezone.ts +134 -0
- package/src/dock-context.ts +47 -0
- package/src/dock-manager-model.test.ts +275 -0
- package/src/dock-manager-model.ts +593 -0
- package/src/dock-model.test.ts +163 -0
- package/src/dock-model.ts +368 -0
- package/src/dock-popout.ts +94 -0
- package/src/editor-contract.ts +2 -0
- package/src/filter-operators.test.ts +59 -0
- package/src/filter-operators.ts +35 -2
- package/src/filtering/excel-filters.test.ts +86 -1
- package/src/filtering/excel-filters.ts +65 -0
- package/src/form-field.ts +87 -0
- package/src/index.ts +158 -4
- package/src/list-nav.test.ts +49 -0
- package/src/list-nav.ts +29 -0
- package/src/list-option.ts +2 -0
- package/src/menu-item.ts +21 -0
- package/src/menus.test.ts +48 -0
- package/src/menus.ts +73 -16
- package/src/panel-resize.ts +52 -0
- package/src/popover.bounds.test.ts +93 -0
- package/src/popover.ts +51 -7
- package/src/recurrence.test.ts +88 -1
- package/src/recurrence.ts +166 -15
- package/src/row-drag.ts +10 -0
- package/src/scheduler-ical.test.ts +68 -0
- package/src/scheduler-ical.ts +180 -0
- package/src/scheduler-model.test.ts +546 -0
- package/src/scheduler-model.ts +853 -0
- package/src/scheduler-view.svelte.ts +33 -0
- package/src/spreadsheet.ts +20 -12
- package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
- package/src/ui-app.types.ts +3 -0
- package/src/virtual.test.ts +6 -4
package/src/recurrence.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* recurrence - a small, pure repeat-pattern engine for SvCalendar (and anywhere
|
|
3
3
|
* you need "does this date match a recurring rule"). Supports daily / weekly /
|
|
4
|
-
* monthly / yearly frequencies with an interval, weekday lists, a day-of-month
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* monthly / yearly frequencies with an interval, weekday lists, a day-of-month
|
|
5
|
+
* (incl. negative = counted from the end), a positional weekday-of-month
|
|
6
|
+
* (`weekOfMonth`, e.g. the 2nd Tuesday / last Friday), an anchor (`from`) and two
|
|
7
|
+
* end conditions - an inclusive `until` date or a `count` of occurrences.
|
|
8
|
+
* Framework-free + pure so it is unit-tested directly - no RRULE dependency.
|
|
7
9
|
*
|
|
8
10
|
* ```ts
|
|
9
11
|
* const standup = { freq: 'weekly', weekdays: [1, 2, 3, 4, 5] } // every weekday
|
|
10
|
-
* const payday = { freq: 'monthly', day: 1 }
|
|
12
|
+
* const payday = { freq: 'monthly', day: -1 } // the last day
|
|
11
13
|
* const sprint = { freq: 'weekly', weekdays: [1], interval: 2, from: '2026-01-05' } // every other Monday
|
|
12
|
-
*
|
|
14
|
+
* const board = { freq: 'monthly', weekdays: [2], weekOfMonth: 1 } // 1st Tuesday
|
|
15
|
+
* const review = { freq: 'weekly', weekdays: [5], count: 8 } // 8 Fridays then stop
|
|
16
|
+
* matchesRecurrence(someDate, [standup, payday, sprint, board, review])
|
|
13
17
|
* ```
|
|
14
18
|
*/
|
|
15
19
|
import { startOfDay, startOfWeek, toDate, type DateLike } from './datetime/date-core'
|
|
@@ -20,23 +24,42 @@ export type RecurrenceRule = {
|
|
|
20
24
|
freq: RecurrenceFreq
|
|
21
25
|
/** Repeat every N units of `freq`. Default 1. Uses `from` as the phase anchor. */
|
|
22
26
|
interval?: number
|
|
23
|
-
/** weekly: the weekdays it lands on (0 = Sunday .. 6 = Saturday).
|
|
27
|
+
/** weekly: the weekdays it lands on (0 = Sunday .. 6 = Saturday). Also the
|
|
28
|
+
* weekday for a positional monthly / yearly rule (with `weekOfMonth`). */
|
|
24
29
|
weekdays?: ReadonlyArray<number>
|
|
25
|
-
/** monthly / yearly: day of the month
|
|
30
|
+
/** monthly / yearly: day of the month. 1..31, or negative to count from the
|
|
31
|
+
* end (-1 = the last day, -2 = the second to last). */
|
|
26
32
|
day?: number
|
|
33
|
+
/** monthly / yearly: the ordinal week the `weekdays` land on within the month -
|
|
34
|
+
* 1..4 (first..fourth) or -1 (last). E.g. `{ weekdays:[2], weekOfMonth:-1 }`
|
|
35
|
+
* = the last Tuesday. Takes precedence over `day` when both are set. */
|
|
36
|
+
weekOfMonth?: number
|
|
27
37
|
/** yearly: the month (0 = January .. 11 = December). */
|
|
28
38
|
month?: number
|
|
29
39
|
/** First occurrence + the phase for `interval`. */
|
|
30
40
|
from?: DateLike
|
|
31
41
|
/** Last day the pattern applies (inclusive). */
|
|
32
42
|
until?: DateLike | null
|
|
43
|
+
/** End after this many occurrences (counted from `from`). Requires `from`. */
|
|
44
|
+
count?: number
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
const MS_DAY = 86_400_000
|
|
36
48
|
/** Whole-day index, rounded so DST transitions don't shift it. */
|
|
37
49
|
const dayIndex = (d: Date) => Math.round(startOfDay(d).getTime() / MS_DAY)
|
|
50
|
+
/** Days in the month that `d` falls in. */
|
|
51
|
+
const daysInMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
|
|
52
|
+
/** Whether `d` is the `nth` occurrence of its own weekday within its month -
|
|
53
|
+
* nth 1..4 (first..fourth) or -1 (last). */
|
|
54
|
+
function isNthWeekdayOfMonth(d: Date, nth: number): boolean {
|
|
55
|
+
if (nth === -1) return d.getDate() + 7 > daysInMonth(d) // no same weekday later this month
|
|
56
|
+
return Math.floor((d.getDate() - 1) / 7) + 1 === nth
|
|
57
|
+
}
|
|
38
58
|
|
|
39
|
-
function matchesRule(date: Date,
|
|
59
|
+
function matchesRule(date: Date, rule: RecurrenceRule): boolean {
|
|
60
|
+
// Fold a `count` end-condition into an equivalent `until` first (pure), so the
|
|
61
|
+
// rest of the matcher stays a stateless per-date test.
|
|
62
|
+
const r = rule.count && rule.count >= 1 && rule.from != null ? resolveCount(rule) : rule
|
|
40
63
|
const d = startOfDay(date)
|
|
41
64
|
const fromD = r.from != null ? startOfDay(toDate(r.from) ?? d) : null
|
|
42
65
|
const untilD = r.until != null ? startOfDay(toDate(r.until) ?? d) : null
|
|
@@ -64,23 +87,59 @@ function matchesRule(date: Date, r: RecurrenceRule): boolean {
|
|
|
64
87
|
}
|
|
65
88
|
|
|
66
89
|
case 'monthly': {
|
|
67
|
-
|
|
68
|
-
if (
|
|
69
|
-
if (!fromD) return dom != null
|
|
90
|
+
if (!matchesMonthDay(d, r)) return false
|
|
91
|
+
if (!fromD) return true
|
|
70
92
|
const months = (d.getFullYear() - fromD.getFullYear()) * 12 + (d.getMonth() - fromD.getMonth())
|
|
71
93
|
return months % interval === 0
|
|
72
94
|
}
|
|
73
95
|
|
|
74
96
|
case 'yearly': {
|
|
75
97
|
if (r.month != null && d.getMonth() !== r.month) return false
|
|
76
|
-
|
|
77
|
-
if (
|
|
78
|
-
if (!fromD) return r.month != null || dom != null
|
|
98
|
+
if (!matchesMonthDay(d, r)) return false
|
|
99
|
+
if (!fromD) return r.month != null || r.day != null || r.weekOfMonth != null
|
|
79
100
|
return (d.getFullYear() - fromD.getFullYear()) % interval === 0
|
|
80
101
|
}
|
|
81
102
|
}
|
|
82
103
|
}
|
|
83
104
|
|
|
105
|
+
// The day-within-the-month test shared by monthly + yearly: a positional
|
|
106
|
+
// weekday (`weekOfMonth` + `weekdays`), else a day-of-month (`day`, negative =
|
|
107
|
+
// from the end), else - for a `from`-anchored rule - the anchor's day-of-month.
|
|
108
|
+
function matchesMonthDay(d: Date, r: RecurrenceRule): boolean {
|
|
109
|
+
if (r.weekOfMonth != null && r.weekdays && r.weekdays.length) {
|
|
110
|
+
return r.weekdays.includes(d.getDay()) && isNthWeekdayOfMonth(d, r.weekOfMonth)
|
|
111
|
+
}
|
|
112
|
+
const fromDom = r.from != null ? (toDate(r.from) ?? d).getDate() : null
|
|
113
|
+
const dom = r.day ?? fromDom
|
|
114
|
+
if (dom == null) return false
|
|
115
|
+
const target = dom < 0 ? daysInMonth(d) + dom + 1 : dom
|
|
116
|
+
return d.getDate() === target
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Fold a `count` rule into an equivalent `until`-bounded rule by walking
|
|
120
|
+
* forward from `from` to the Nth occurrence. Pure; count is then stripped. */
|
|
121
|
+
function resolveCount(rule: RecurrenceRule): RecurrenceRule {
|
|
122
|
+
const from = toDate(rule.from!)
|
|
123
|
+
if (!from) return { ...rule, count: undefined }
|
|
124
|
+
const base: RecurrenceRule = { ...rule, count: undefined }
|
|
125
|
+
let cur = startOfDay(from)
|
|
126
|
+
let n = 0
|
|
127
|
+
let lastMatch = cur
|
|
128
|
+
let guard = 0
|
|
129
|
+
while (guard++ < 4000 * 12) {
|
|
130
|
+
if (matchesRule(cur, base)) {
|
|
131
|
+
n++
|
|
132
|
+
lastMatch = cur
|
|
133
|
+
if (n >= rule.count!) break
|
|
134
|
+
}
|
|
135
|
+
cur = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + 1)
|
|
136
|
+
}
|
|
137
|
+
const untilFromCount = lastMatch
|
|
138
|
+
const existingUntil = rule.until != null ? startOfDay(toDate(rule.until) ?? untilFromCount) : null
|
|
139
|
+
const until = existingUntil && existingUntil < untilFromCount ? existingUntil : untilFromCount
|
|
140
|
+
return { ...base, until }
|
|
141
|
+
}
|
|
142
|
+
|
|
84
143
|
/** Whether `date` matches any of the given recurrence rule(s). */
|
|
85
144
|
export function matchesRecurrence(
|
|
86
145
|
date: Date,
|
|
@@ -99,13 +158,105 @@ export function expandRecurrence(
|
|
|
99
158
|
end: Date,
|
|
100
159
|
): Date[] {
|
|
101
160
|
if (!rules) return []
|
|
161
|
+
// Pre-resolve any `count` end-condition into a plain `until` once (rather than
|
|
162
|
+
// re-walking to the Nth occurrence for every day in the window).
|
|
163
|
+
const list = (Array.isArray(rules) ? rules : [rules]).map((r) =>
|
|
164
|
+
r.count && r.count >= 1 && r.from != null ? resolveCount(r) : r,
|
|
165
|
+
)
|
|
102
166
|
const out: Date[] = []
|
|
103
167
|
const last = startOfDay(end)
|
|
104
168
|
let cur = startOfDay(start)
|
|
105
169
|
let guard = 0
|
|
106
170
|
while (cur <= last && guard++ < 4000) {
|
|
107
|
-
if (matchesRecurrence(cur,
|
|
171
|
+
if (matchesRecurrence(cur, list)) out.push(cur)
|
|
108
172
|
cur = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + 1)
|
|
109
173
|
}
|
|
110
174
|
return out
|
|
111
175
|
}
|
|
176
|
+
|
|
177
|
+
// --- human-readable summaries (for a table cell, a chip, a tooltip) ----------
|
|
178
|
+
|
|
179
|
+
/** Optional English strings a caller can override for i18n. */
|
|
180
|
+
export type RecurrenceLabels = {
|
|
181
|
+
weekdays?: ReadonlyArray<string> // Sun..Sat
|
|
182
|
+
months?: ReadonlyArray<string> // Jan..Dec
|
|
183
|
+
ordinals?: Record<number, string> // 1..5 + -1
|
|
184
|
+
never?: string
|
|
185
|
+
}
|
|
186
|
+
const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
187
|
+
const MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
188
|
+
const ORD: Record<number, string> = { 1: 'first', 2: 'second', 3: 'third', 4: 'fourth', 5: 'fifth', [-1]: 'last' }
|
|
189
|
+
|
|
190
|
+
// Where in the month a monthly / yearly rule lands, as a phrase.
|
|
191
|
+
function whereInMonth(r: RecurrenceRule, wd: ReadonlyArray<string>, ord: Record<number, string>): string {
|
|
192
|
+
if (r.weekOfMonth != null && r.weekdays && r.weekdays.length) {
|
|
193
|
+
return `the ${ord[r.weekOfMonth] ?? r.weekOfMonth} ${wd[r.weekdays[0]]}`
|
|
194
|
+
}
|
|
195
|
+
if (r.day === -1) return 'the last day'
|
|
196
|
+
if (r.day != null && r.day < 0) return `the ${-r.day} day(s) from the end`
|
|
197
|
+
if (r.day != null) return `day ${r.day}`
|
|
198
|
+
return ''
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function describeRule(r: RecurrenceRule, L: Required<RecurrenceLabels>): string {
|
|
202
|
+
const n = Math.max(1, Math.floor(r.interval ?? 1))
|
|
203
|
+
let s = ''
|
|
204
|
+
switch (r.freq) {
|
|
205
|
+
case 'daily':
|
|
206
|
+
s = n === 1 ? 'Daily' : `Every ${n} days`
|
|
207
|
+
break
|
|
208
|
+
case 'weekly': {
|
|
209
|
+
s = n === 1 ? 'Weekly' : `Every ${n} weeks`
|
|
210
|
+
const wds = r.weekdays && r.weekdays.length ? [...r.weekdays].sort((a, b) => a - b) : []
|
|
211
|
+
if (wds.length) {
|
|
212
|
+
const isWeekdays = wds.length === 5 && [1, 2, 3, 4, 5].every((d) => wds.includes(d))
|
|
213
|
+
const isWeekend = wds.length === 2 && wds.includes(0) && wds.includes(6)
|
|
214
|
+
s += isWeekdays ? ' on weekdays' : isWeekend ? ' on weekends' : ` on ${wds.map((d) => L.weekdays[d]).join(', ')}`
|
|
215
|
+
}
|
|
216
|
+
break
|
|
217
|
+
}
|
|
218
|
+
case 'monthly': {
|
|
219
|
+
s = n === 1 ? 'Monthly' : `Every ${n} months`
|
|
220
|
+
const where = whereInMonth(r, L.weekdays, L.ordinals)
|
|
221
|
+
if (where) s += ` on ${where}`
|
|
222
|
+
break
|
|
223
|
+
}
|
|
224
|
+
case 'yearly': {
|
|
225
|
+
s = n === 1 ? 'Yearly' : `Every ${n} years`
|
|
226
|
+
const where = whereInMonth(r, L.weekdays, L.ordinals)
|
|
227
|
+
if (r.month != null && where) s += ` on ${where} of ${L.months[r.month]}`
|
|
228
|
+
else if (r.month != null) s += ` in ${L.months[r.month]}`
|
|
229
|
+
else if (where) s += ` on ${where}`
|
|
230
|
+
break
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// End condition.
|
|
234
|
+
if (r.count && r.count >= 1) s += `, ${r.count} time${r.count === 1 ? '' : 's'}`
|
|
235
|
+
else if (r.until) {
|
|
236
|
+
const u = toDate(r.until)
|
|
237
|
+
if (u) s += ` until ${L.months[u.getMonth()]} ${u.getDate()}, ${u.getFullYear()}`
|
|
238
|
+
}
|
|
239
|
+
return s
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A short, human-readable summary of a recurrence rule (or list) - e.g.
|
|
244
|
+
* "Weekly on weekdays", "Every 2 weeks on Fri, 8 times", "Monthly on the last
|
|
245
|
+
* Friday", "Yearly on the fourth Thursday of Nov". Returns the `never` label
|
|
246
|
+
* (default "") for an empty / falsy rule, so it drops cleanly into a table cell.
|
|
247
|
+
*/
|
|
248
|
+
export function describeRecurrence(
|
|
249
|
+
rules: RecurrenceRule | ReadonlyArray<RecurrenceRule> | null | undefined,
|
|
250
|
+
labels?: RecurrenceLabels,
|
|
251
|
+
): string {
|
|
252
|
+
const L: Required<RecurrenceLabels> = {
|
|
253
|
+
weekdays: labels?.weekdays ?? WD,
|
|
254
|
+
months: labels?.months ?? MO,
|
|
255
|
+
ordinals: labels?.ordinals ?? ORD,
|
|
256
|
+
never: labels?.never ?? '',
|
|
257
|
+
}
|
|
258
|
+
if (!rules) return L.never
|
|
259
|
+
const list = (Array.isArray(rules) ? rules : [rules]).filter((r) => r && r.freq)
|
|
260
|
+
if (!list.length) return L.never
|
|
261
|
+
return list.map((r) => describeRule(r, L)).join('; ')
|
|
262
|
+
}
|
package/src/row-drag.ts
CHANGED
|
@@ -178,6 +178,16 @@ export function createRowDrag<TFeatures, TData>(ctx: any) {
|
|
|
178
178
|
onRowsContainerDrop,
|
|
179
179
|
onRowDragEnd,
|
|
180
180
|
rowDragGridId: gridId,
|
|
181
|
+
/**
|
|
182
|
+
* Clear any managed drag this grid currently owns. Call on unmount so an
|
|
183
|
+
* interrupted drag (route change / `{#if}` toggle mid-drag) doesn't leave a
|
|
184
|
+
* stale module-level `bus` that fires `removeFromSource` against a destroyed
|
|
185
|
+
* grid's data on the next grid instance (#68). Guarded by `gridId` so it
|
|
186
|
+
* never disturbs a concurrent drag owned by another grid.
|
|
187
|
+
*/
|
|
188
|
+
destroyRowDrag() {
|
|
189
|
+
if (bus?.gridId === gridId) bus = null;
|
|
190
|
+
},
|
|
181
191
|
};
|
|
182
192
|
}
|
|
183
193
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { toICS, fromICS, ruleToRRule, rruleToRule, type ICalEvent } from './scheduler-ical'
|
|
3
|
+
import type { RecurrenceRule } from './recurrence'
|
|
4
|
+
|
|
5
|
+
describe('iCal RRULE round-trip', () => {
|
|
6
|
+
const cases: [RecurrenceRule, string][] = [
|
|
7
|
+
[{ freq: 'weekly', weekdays: [1, 3, 5] }, 'FREQ=WEEKLY;BYDAY=MO,WE,FR'],
|
|
8
|
+
[{ freq: 'daily', interval: 2 }, 'FREQ=DAILY;INTERVAL=2'],
|
|
9
|
+
[{ freq: 'monthly', day: -1 }, 'FREQ=MONTHLY;BYMONTHDAY=-1'],
|
|
10
|
+
[{ freq: 'monthly', weekdays: [2], weekOfMonth: 1 }, 'FREQ=MONTHLY;BYDAY=1TU'],
|
|
11
|
+
[{ freq: 'monthly', weekdays: [5], weekOfMonth: -1 }, 'FREQ=MONTHLY;BYDAY=-1FR'],
|
|
12
|
+
[{ freq: 'yearly', month: 10, weekdays: [4], weekOfMonth: 4 }, 'FREQ=YEARLY;BYDAY=4TH;BYMONTH=11'],
|
|
13
|
+
[{ freq: 'weekly', weekdays: [1], count: 8 }, 'FREQ=WEEKLY;BYDAY=MO;COUNT=8'],
|
|
14
|
+
]
|
|
15
|
+
it('serializes rules to RRULE', () => {
|
|
16
|
+
for (const [rule, str] of cases) expect(ruleToRRule(rule)).toBe(str)
|
|
17
|
+
})
|
|
18
|
+
it('parses RRULE back to an equivalent rule', () => {
|
|
19
|
+
for (const [rule, str] of cases) expect(rruleToRule(str)).toMatchObject(rule)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('toICS / fromICS', () => {
|
|
24
|
+
const events: ICalEvent[] = [
|
|
25
|
+
{ uid: 'a@x', title: 'Kickoff, prep', start: new Date('2026-07-06T09:00:00Z'), end: new Date('2026-07-06T10:00:00Z'), status: 'confirmed' },
|
|
26
|
+
{ uid: 'b@x', title: 'Offsite', start: new Date('2026-07-08T00:00:00Z'), end: new Date('2026-07-10T00:00:00Z'), allDay: true },
|
|
27
|
+
{ uid: 'c@x', title: 'Standup', start: new Date('2026-07-06T09:30:00Z'), end: new Date('2026-07-06T09:45:00Z'), rrule: { freq: 'weekly', weekdays: [1, 2, 3, 4, 5] } },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
it('produces a valid VCALENDAR with one VEVENT per event', () => {
|
|
31
|
+
const ics = toICS(events)
|
|
32
|
+
expect(ics).toMatch(/^BEGIN:VCALENDAR/)
|
|
33
|
+
expect(ics).toContain('BEGIN:VEVENT')
|
|
34
|
+
expect((ics.match(/BEGIN:VEVENT/g) ?? []).length).toBe(3)
|
|
35
|
+
expect(ics).toContain('DTSTART:20260706T090000Z')
|
|
36
|
+
expect(ics).toContain('DTSTART;VALUE=DATE:20260708')
|
|
37
|
+
expect(ics).toContain('RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR')
|
|
38
|
+
expect(ics).toContain('SUMMARY:Kickoff\\, prep') // comma escaped
|
|
39
|
+
expect(ics.endsWith('END:VCALENDAR')).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('round-trips events through export -> import', () => {
|
|
43
|
+
const back = fromICS(toICS(events))
|
|
44
|
+
expect(back).toHaveLength(3)
|
|
45
|
+
expect(back[0]!.title).toBe('Kickoff, prep') // unescaped
|
|
46
|
+
expect(back[0]!.start.toISOString()).toBe('2026-07-06T09:00:00.000Z')
|
|
47
|
+
expect(back[1]!.allDay).toBe(true)
|
|
48
|
+
expect(back[2]!.rrule).toMatchObject({ freq: 'weekly', weekdays: [1, 2, 3, 4, 5] })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('imports a hand-written ICS (folded lines + CRLF)', () => {
|
|
52
|
+
const ics = [
|
|
53
|
+
'BEGIN:VCALENDAR',
|
|
54
|
+
'VERSION:2.0',
|
|
55
|
+
'BEGIN:VEVENT',
|
|
56
|
+
'UID:z@x',
|
|
57
|
+
'DTSTART:20260706T140000Z',
|
|
58
|
+
'DTEND:20260706T150000Z',
|
|
59
|
+
'SUMMARY:Client call',
|
|
60
|
+
'END:VEVENT',
|
|
61
|
+
'END:VCALENDAR',
|
|
62
|
+
].join('\r\n')
|
|
63
|
+
const out = fromICS(ics)
|
|
64
|
+
expect(out).toHaveLength(1)
|
|
65
|
+
expect(out[0]!.title).toBe('Client call')
|
|
66
|
+
expect(out[0]!.end.toISOString()).toBe('2026-07-06T15:00:00.000Z')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-ical - pure, dependency-free iCalendar (RFC 5545) import / export for
|
|
3
|
+
* the scheduler. Works on a small {@link ICalEvent} shape so it stays data-model
|
|
4
|
+
* agnostic: map your rows to/from it. Handles timed + all-day events, RRULE
|
|
5
|
+
* round-trip (via the shared {@link RecurrenceRule}), STATUS, DESCRIPTION and
|
|
6
|
+
* LOCATION. No `Date.now()` in the core - `toICS` accepts a stamp.
|
|
7
|
+
*/
|
|
8
|
+
import type { RecurrenceRule } from './recurrence'
|
|
9
|
+
|
|
10
|
+
export type ICalEvent = {
|
|
11
|
+
uid?: string
|
|
12
|
+
title: string
|
|
13
|
+
description?: string
|
|
14
|
+
location?: string
|
|
15
|
+
start: Date
|
|
16
|
+
end: Date
|
|
17
|
+
allDay?: boolean
|
|
18
|
+
status?: 'confirmed' | 'tentative' | 'cancelled'
|
|
19
|
+
rrule?: RecurrenceRule | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const WD = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const
|
|
23
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
24
|
+
|
|
25
|
+
const utcStamp = (d: Date) =>
|
|
26
|
+
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`
|
|
27
|
+
const dateOnly = (d: Date) => `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`
|
|
28
|
+
|
|
29
|
+
const escText = (s: string) =>
|
|
30
|
+
s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n')
|
|
31
|
+
const unescText = (s: string) =>
|
|
32
|
+
s.replace(/\\n/gi, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\')
|
|
33
|
+
|
|
34
|
+
/** Fold a content line at 74 octets (RFC 5545 line folding). */
|
|
35
|
+
function fold(line: string): string {
|
|
36
|
+
if (line.length <= 74) return line
|
|
37
|
+
let out = line.slice(0, 74)
|
|
38
|
+
let rest = line.slice(74)
|
|
39
|
+
while (rest.length > 73) {
|
|
40
|
+
out += '\r\n ' + rest.slice(0, 73)
|
|
41
|
+
rest = rest.slice(73)
|
|
42
|
+
}
|
|
43
|
+
return out + '\r\n ' + rest
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- RRULE serialize / parse ------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** A {@link RecurrenceRule} -> an iCal `RRULE` value (without the `RRULE:` name). */
|
|
49
|
+
export function ruleToRRule(rule: RecurrenceRule): string {
|
|
50
|
+
const parts = [`FREQ=${rule.freq.toUpperCase()}`]
|
|
51
|
+
if (rule.interval && rule.interval > 1) parts.push(`INTERVAL=${rule.interval}`)
|
|
52
|
+
if (rule.weekdays?.length) {
|
|
53
|
+
if (rule.weekOfMonth != null && rule.weekdays.length === 1) {
|
|
54
|
+
parts.push(`BYDAY=${rule.weekOfMonth}${WD[rule.weekdays[0]!]}`)
|
|
55
|
+
} else {
|
|
56
|
+
parts.push(`BYDAY=${rule.weekdays.map((w) => WD[w]).join(',')}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (rule.day != null && rule.weekOfMonth == null) parts.push(`BYMONTHDAY=${rule.day}`)
|
|
60
|
+
if (rule.month != null) parts.push(`BYMONTH=${rule.month + 1}`)
|
|
61
|
+
if (rule.count != null) parts.push(`COUNT=${rule.count}`)
|
|
62
|
+
if (rule.until != null) parts.push(`UNTIL=${utcStamp(new Date(rule.until as string))}`)
|
|
63
|
+
return parts.join(';')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** An iCal `RRULE` value -> a {@link RecurrenceRule}. */
|
|
67
|
+
export function rruleToRule(value: string): RecurrenceRule | null {
|
|
68
|
+
const map: Record<string, string> = {}
|
|
69
|
+
for (const kv of value.split(';')) {
|
|
70
|
+
const [k, v] = kv.split('=')
|
|
71
|
+
if (k && v) map[k.toUpperCase()] = v
|
|
72
|
+
}
|
|
73
|
+
const freq = (map.FREQ ?? '').toLowerCase()
|
|
74
|
+
if (!['daily', 'weekly', 'monthly', 'yearly'].includes(freq)) return null
|
|
75
|
+
const rule: RecurrenceRule = { freq: freq as RecurrenceRule['freq'] }
|
|
76
|
+
if (map.INTERVAL) rule.interval = Number(map.INTERVAL)
|
|
77
|
+
if (map.BYDAY) {
|
|
78
|
+
const parts = map.BYDAY.split(',')
|
|
79
|
+
const m = parts[0]!.match(/^(-?\d+)?([A-Z]{2})$/)
|
|
80
|
+
if (parts.length === 1 && m && m[1]) {
|
|
81
|
+
rule.weekOfMonth = Number(m[1])
|
|
82
|
+
rule.weekdays = [WD.indexOf(m[2] as (typeof WD)[number])]
|
|
83
|
+
} else {
|
|
84
|
+
rule.weekdays = parts.map((p) => WD.indexOf(p.replace(/^-?\d+/, '') as (typeof WD)[number])).filter((n) => n >= 0)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (map.BYMONTHDAY) rule.day = Number(map.BYMONTHDAY)
|
|
88
|
+
if (map.BYMONTH) rule.month = Number(map.BYMONTH) - 1
|
|
89
|
+
if (map.COUNT) rule.count = Number(map.COUNT)
|
|
90
|
+
if (map.UNTIL) rule.until = parseICalDate(map.UNTIL).date.toISOString()
|
|
91
|
+
return rule
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- export -----------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/** Serialize events to an iCalendar string. `stamp` sets DTSTAMP (default epoch). */
|
|
97
|
+
export function toICS(events: ReadonlyArray<ICalEvent>, opts: { prodId?: string; stamp?: Date } = {}): string {
|
|
98
|
+
const stamp = utcStamp(opts.stamp ?? new Date(0))
|
|
99
|
+
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', `PRODID:${opts.prodId ?? '-//SvGrid//Scheduler//EN'}`]
|
|
100
|
+
events.forEach((e, i) => {
|
|
101
|
+
lines.push('BEGIN:VEVENT')
|
|
102
|
+
lines.push(`UID:${e.uid ?? `svgrid-${i}-${e.start.getTime()}`}`)
|
|
103
|
+
lines.push(`DTSTAMP:${stamp}`)
|
|
104
|
+
if (e.allDay) {
|
|
105
|
+
lines.push(`DTSTART;VALUE=DATE:${dateOnly(e.start)}`)
|
|
106
|
+
lines.push(`DTEND;VALUE=DATE:${dateOnly(e.end)}`)
|
|
107
|
+
} else {
|
|
108
|
+
lines.push(`DTSTART:${utcStamp(e.start)}`)
|
|
109
|
+
lines.push(`DTEND:${utcStamp(e.end)}`)
|
|
110
|
+
}
|
|
111
|
+
lines.push(`SUMMARY:${escText(e.title)}`)
|
|
112
|
+
if (e.description) lines.push(`DESCRIPTION:${escText(e.description)}`)
|
|
113
|
+
if (e.location) lines.push(`LOCATION:${escText(e.location)}`)
|
|
114
|
+
if (e.status) lines.push(`STATUS:${e.status.toUpperCase()}`)
|
|
115
|
+
if (e.rrule) lines.push(`RRULE:${ruleToRRule(e.rrule)}`)
|
|
116
|
+
lines.push('END:VEVENT')
|
|
117
|
+
})
|
|
118
|
+
lines.push('END:VCALENDAR')
|
|
119
|
+
return lines.map(fold).join('\r\n')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- import -----------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
function parseICalDate(value: string): { date: Date; allDay: boolean } {
|
|
125
|
+
// 20260706 (date) | 20260706T093000Z (utc) | 20260706T093000 (local/floating)
|
|
126
|
+
const m = value.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/)
|
|
127
|
+
if (!m) return { date: new Date(NaN), allDay: false }
|
|
128
|
+
const [, y, mo, d, hh, mm, ss, z] = m
|
|
129
|
+
if (hh == null) return { date: new Date(Number(y), Number(mo) - 1, Number(d)), allDay: true }
|
|
130
|
+
if (z) return { date: new Date(Date.UTC(+y!, +mo! - 1, +d!, +hh, +mm!, +ss!)), allDay: false }
|
|
131
|
+
return { date: new Date(+y!, +mo! - 1, +d!, +hh, +mm!, +ss!), allDay: false }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Parse an iCalendar string into events (unfolds lines, reads VEVENT blocks). */
|
|
135
|
+
export function fromICS(text: string): ICalEvent[] {
|
|
136
|
+
// Unfold: a line starting with space/tab continues the previous one.
|
|
137
|
+
const raw = text.replace(/\r\n/g, '\n').split('\n')
|
|
138
|
+
const unfolded: string[] = []
|
|
139
|
+
for (const line of raw) {
|
|
140
|
+
if ((line.startsWith(' ') || line.startsWith('\t')) && unfolded.length) {
|
|
141
|
+
unfolded[unfolded.length - 1] += line.slice(1)
|
|
142
|
+
} else unfolded.push(line)
|
|
143
|
+
}
|
|
144
|
+
const out: ICalEvent[] = []
|
|
145
|
+
let cur: Partial<ICalEvent> & { _allDayStart?: boolean } = {}
|
|
146
|
+
let inEvent = false
|
|
147
|
+
for (const line of unfolded) {
|
|
148
|
+
if (line === 'BEGIN:VEVENT') {
|
|
149
|
+
inEvent = true
|
|
150
|
+
cur = {}
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
if (line === 'END:VEVENT') {
|
|
154
|
+
if (cur.title && cur.start && cur.end) out.push({ title: cur.title, start: cur.start, end: cur.end, allDay: cur.allDay, uid: cur.uid, description: cur.description, location: cur.location, status: cur.status, rrule: cur.rrule ?? null })
|
|
155
|
+
inEvent = false
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (!inEvent) continue
|
|
159
|
+
const ci = line.indexOf(':')
|
|
160
|
+
if (ci < 0) continue
|
|
161
|
+
const nameAndParams = line.slice(0, ci)
|
|
162
|
+
const value = line.slice(ci + 1)
|
|
163
|
+
const name = nameAndParams.split(';')[0]!.toUpperCase()
|
|
164
|
+
if (name === 'UID') cur.uid = value
|
|
165
|
+
else if (name === 'SUMMARY') cur.title = unescText(value)
|
|
166
|
+
else if (name === 'DESCRIPTION') cur.description = unescText(value)
|
|
167
|
+
else if (name === 'LOCATION') cur.location = unescText(value)
|
|
168
|
+
else if (name === 'STATUS') cur.status = value.toLowerCase() as ICalEvent['status']
|
|
169
|
+
else if (name === 'DTSTART') {
|
|
170
|
+
const { date, allDay } = parseICalDate(value)
|
|
171
|
+
cur.start = date
|
|
172
|
+
cur.allDay = allDay
|
|
173
|
+
} else if (name === 'DTEND') {
|
|
174
|
+
cur.end = parseICalDate(value).date
|
|
175
|
+
} else if (name === 'RRULE') {
|
|
176
|
+
cur.rrule = rruleToRule(value) ?? undefined
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return out
|
|
180
|
+
}
|