@voyantjs/availability 0.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/dist/rrule.js ADDED
@@ -0,0 +1,201 @@
1
+ export const WEEKDAYS = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
2
+ export const WEEKDAY_LABELS = {
3
+ MO: "Mon",
4
+ TU: "Tue",
5
+ WE: "Wed",
6
+ TH: "Thu",
7
+ FR: "Fri",
8
+ SA: "Sat",
9
+ SU: "Sun",
10
+ };
11
+ /**
12
+ * Parse a minimal RRULE string (subset of RFC 5545).
13
+ * Supports FREQ, INTERVAL, BYDAY, BYMONTHDAY.
14
+ */
15
+ export function parseRRule(rrule) {
16
+ const parts = rrule
17
+ .split(";")
18
+ .map((p) => p.trim())
19
+ .filter(Boolean);
20
+ const map = new Map();
21
+ for (const part of parts) {
22
+ const [key, value] = part.split("=");
23
+ if (key && value !== undefined)
24
+ map.set(key.toUpperCase(), value);
25
+ }
26
+ const rawFreq = (map.get("FREQ") ?? "DAILY").toUpperCase();
27
+ const frequency = rawFreq === "WEEKLY" || rawFreq === "MONTHLY" ? rawFreq : "DAILY";
28
+ const interval = Number.parseInt(map.get("INTERVAL") ?? "1", 10) || 1;
29
+ const byday = map.get("BYDAY") ?? "";
30
+ const byWeekdays = byday
31
+ .split(",")
32
+ .map((d) => d.trim().toUpperCase())
33
+ .filter((d) => WEEKDAYS.includes(d));
34
+ const bymonthday = map.get("BYMONTHDAY") ?? "";
35
+ const byMonthDays = bymonthday
36
+ .split(",")
37
+ .map((d) => Number.parseInt(d.trim(), 10))
38
+ .filter((n) => Number.isFinite(n) && n >= 1 && n <= 31);
39
+ return { frequency, interval, byWeekdays, byMonthDays };
40
+ }
41
+ /**
42
+ * Build an RRULE string from structured values.
43
+ */
44
+ export function buildRRule(values) {
45
+ const parts = [`FREQ=${values.frequency}`];
46
+ if (values.interval > 1)
47
+ parts.push(`INTERVAL=${values.interval}`);
48
+ if (values.frequency === "WEEKLY" && values.byWeekdays.length > 0) {
49
+ const ordered = WEEKDAYS.filter((d) => values.byWeekdays.includes(d));
50
+ parts.push(`BYDAY=${ordered.join(",")}`);
51
+ }
52
+ if (values.frequency === "MONTHLY" && values.byMonthDays.length > 0) {
53
+ const ordered = [...values.byMonthDays].sort((a, b) => a - b);
54
+ parts.push(`BYMONTHDAY=${ordered.join(",")}`);
55
+ }
56
+ return parts.join(";");
57
+ }
58
+ /**
59
+ * Human-readable preview: "Every Monday" / "Every 2 weeks on Mon, Wed, Fri"
60
+ */
61
+ export function describeRRule(rrule) {
62
+ const parsed = typeof rrule === "string" ? parseRRule(rrule) : rrule;
63
+ const { frequency, interval, byWeekdays, byMonthDays } = parsed;
64
+ const unit = frequency === "DAILY" ? "day" : frequency === "WEEKLY" ? "week" : "month";
65
+ const cadence = interval > 1 ? `Every ${interval} ${unit}s` : `Every ${unit}`;
66
+ if (frequency === "WEEKLY") {
67
+ if (byWeekdays.length === 0)
68
+ return `${cadence} (no weekdays)`;
69
+ const ordered = WEEKDAYS.filter((d) => byWeekdays.includes(d));
70
+ if (interval === 1 && ordered.length === 1) {
71
+ // "Every Monday"
72
+ const fullNames = {
73
+ MO: "Monday",
74
+ TU: "Tuesday",
75
+ WE: "Wednesday",
76
+ TH: "Thursday",
77
+ FR: "Friday",
78
+ SA: "Saturday",
79
+ SU: "Sunday",
80
+ };
81
+ return `Every ${fullNames[ordered[0]]}`;
82
+ }
83
+ const labels = ordered.map((d) => WEEKDAY_LABELS[d]);
84
+ return `${cadence} on ${labels.join(", ")}`;
85
+ }
86
+ if (frequency === "MONTHLY") {
87
+ if (byMonthDays.length === 0)
88
+ return `${cadence} (no days)`;
89
+ const ordered = [...byMonthDays].sort((a, b) => a - b);
90
+ return `${cadence} on day${ordered.length === 1 ? "" : "s"} ${ordered.join(", ")}`;
91
+ }
92
+ return cadence;
93
+ }
94
+ function addDaysUtc(date, days) {
95
+ const d = new Date(date);
96
+ d.setUTCDate(d.getUTCDate() + days);
97
+ return d;
98
+ }
99
+ function startOfDayUtc(date) {
100
+ const d = new Date(date);
101
+ d.setUTCHours(0, 0, 0, 0);
102
+ return d;
103
+ }
104
+ function weekdayFromJsDay(jsDay) {
105
+ // JS getUTCDay: 0=Sun, 1=Mon, ... 6=Sat
106
+ const map = {
107
+ 0: "SU",
108
+ 1: "MO",
109
+ 2: "TU",
110
+ 3: "WE",
111
+ 4: "TH",
112
+ 5: "FR",
113
+ 6: "SA",
114
+ };
115
+ return map[jsDay] ?? "MO";
116
+ }
117
+ function mondayOfWeekUtc(date) {
118
+ // JS Monday = 1, Sunday = 0. Convert Sunday → 7 for ISO week alignment.
119
+ const d = startOfDayUtc(date);
120
+ const jsDay = d.getUTCDay();
121
+ const iso = jsDay === 0 ? 7 : jsDay;
122
+ return addDaysUtc(d, -(iso - 1));
123
+ }
124
+ function formatDateLocal(date) {
125
+ const y = date.getUTCFullYear();
126
+ const m = String(date.getUTCMonth() + 1).padStart(2, "0");
127
+ const d = String(date.getUTCDate()).padStart(2, "0");
128
+ return `${y}-${m}-${d}`;
129
+ }
130
+ /**
131
+ * Expand an RRULE into a sorted list of local date strings (YYYY-MM-DD).
132
+ * Operates on UTC-anchored wall-clock dates — timezone conversion is
133
+ * the caller's responsibility.
134
+ */
135
+ export function expandRRule(rrule, fromDate, toDate, limit = 1000) {
136
+ const parsed = typeof rrule === "string" ? parseRRule(rrule) : rrule;
137
+ const start = startOfDayUtc(fromDate);
138
+ const end = startOfDayUtc(toDate);
139
+ if (end < start)
140
+ return [];
141
+ const interval = Math.max(1, parsed.interval);
142
+ const out = [];
143
+ if (parsed.frequency === "DAILY") {
144
+ let cursor = start;
145
+ while (cursor <= end && out.length < limit) {
146
+ out.push(formatDateLocal(cursor));
147
+ cursor = addDaysUtc(cursor, interval);
148
+ }
149
+ return out;
150
+ }
151
+ if (parsed.frequency === "WEEKLY") {
152
+ if (parsed.byWeekdays.length === 0)
153
+ return [];
154
+ const target = new Set(parsed.byWeekdays);
155
+ let weekStart = mondayOfWeekUtc(start);
156
+ while (weekStart <= end && out.length < limit) {
157
+ for (let i = 0; i < 7; i++) {
158
+ const day = addDaysUtc(weekStart, i);
159
+ if (day < start || day > end)
160
+ continue;
161
+ const wd = weekdayFromJsDay(day.getUTCDay());
162
+ if (target.has(wd))
163
+ out.push(formatDateLocal(day));
164
+ if (out.length >= limit)
165
+ break;
166
+ }
167
+ weekStart = addDaysUtc(weekStart, 7 * interval);
168
+ }
169
+ return out;
170
+ }
171
+ // MONTHLY
172
+ if (parsed.byMonthDays.length === 0)
173
+ return [];
174
+ const monthDays = [...parsed.byMonthDays].sort((a, b) => a - b);
175
+ let year = start.getUTCFullYear();
176
+ let month = start.getUTCMonth();
177
+ while (out.length < limit) {
178
+ const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
179
+ for (const dom of monthDays) {
180
+ if (dom > daysInMonth)
181
+ continue;
182
+ const d = new Date(Date.UTC(year, month, dom));
183
+ if (d < start)
184
+ continue;
185
+ if (d > end)
186
+ return out;
187
+ out.push(formatDateLocal(d));
188
+ if (out.length >= limit)
189
+ return out;
190
+ }
191
+ month += interval;
192
+ while (month > 11) {
193
+ month -= 12;
194
+ year += 1;
195
+ }
196
+ const firstOfNextIterMonth = new Date(Date.UTC(year, month, 1));
197
+ if (firstOfNextIterMonth > end)
198
+ break;
199
+ }
200
+ return out;
201
+ }