alf: the atproto Latency Fabric alf.fly.dev/
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add NLP schedule parser/formatter and disable-recurring flag

- packages/recurrence: parseRecurrenceRule() — plain-English → RecurrenceRule
(daily, weekly, monthly, quarterly, yearly; timezone abbrs; end conditions)
- packages/recurrence: formatRecurrenceRule() — RecurrenceRule → plain English
- Demo: NLP input in recurring form; Parse button populates all fields
- Demo: schedule cards use formatRecurrenceRule instead of hand-rolled describeRule
- DISABLE_RECURRING env var: rejects createSchedule at server level and shows
a notice in the demo UI (single deferred posts are unaffected)
- packages/recurrence README; docs/api.md client utilities section
- Dead code cleanup in parser.ts (unreachable yearly_on_month_day branch)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+1632 -54
+5
.env.example
··· 20 20 # Called with POST { uri, publishedAt } after each draft is successfully published 21 21 # POST_PUBLISH_WEBHOOK_URL=https://your-service.example.com/hooks/post-published 22 22 23 + # Disable recurring schedules (optional) 24 + # Set to true to reject createSchedule requests and show a notice in the demo UI. 25 + # Useful for public demo deployments to prevent abuse. 26 + # DISABLE_RECURRING=true 27 + 23 28 # Per-user draft limit (optional) 24 29 # Maximum number of active (draft/scheduled) posts per user. Unset = unlimited. 25 30 # MAX_DRAFTS_PER_USER=3
+141 -54
demo/client/index.ts
··· 3 3 4 4 import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; 5 5 import type { OAuthSession } from '@atproto/oauth-client-browser'; 6 + import { parseRecurrenceRule, formatRecurrenceRule } from '@newpublic/recurrence'; 6 7 7 8 // --------------------------------------------------------------------------- 8 9 // State ··· 93 94 userLabel = await resolveUserLabel(did); 94 95 95 96 let alfAuthorized = false; 97 + let recurringDisabled = false; 96 98 try { 97 99 const response = await alfFetch('/oauth/status'); 98 100 if (response.ok) { 99 - const data = await response.json() as { authorized?: boolean }; 101 + const data = await response.json() as { authorized?: boolean; disableRecurring?: boolean }; 100 102 alfAuthorized = data.authorized === true; 103 + recurringDisabled = data.disableRecurring === true; 101 104 } 102 105 } catch (_) { 103 106 // ALF unreachable or not authorized ··· 114 117 115 118 document.getElementById('did-label-3')!.textContent = userLabel; 116 119 showView('view-authorized'); 120 + applyRecurringDisabled(recurringDisabled); 117 121 await loadPosts(); 118 122 await loadSchedules(); 119 123 if (postsInterval) clearInterval(postsInterval); ··· 758 762 }); 759 763 } 760 764 761 - function describeRule(rule: Record<string, any>, timezone: string): string { 762 - const time = (rule.time as Record<string, any>) || {}; 763 - const hour: number = time.hour ?? 0; 764 - const minute: number = time.minute ?? 0; 765 - const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; 766 - const tz = timezone || (time.timezone as string) || 'UTC'; 767 - 768 - if (rule.type === 'daily') { 769 - const interval: number = rule.interval ?? 1; 770 - return interval === 1 771 - ? `Daily at ${timeStr} ${tz}` 772 - : `Every ${interval} days at ${timeStr} ${tz}`; 773 - } 774 - if (rule.type === 'weekly') { 775 - const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 776 - const days = ((rule.daysOfWeek as number[]) ?? []).map((d: number) => dayNames[d] ?? String(d)).join(', '); 777 - return `Weekly on ${days} at ${timeStr} ${tz}`; 778 - } 779 - if (rule.type === 'monthly_on_day') { 780 - return `Monthly on day ${rule.dayOfMonth as number} at ${timeStr} ${tz}`; 781 - } 782 - if (rule.type === 'monthly_nth_weekday') { 783 - const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 784 - const nth = (rule.nth as number) === -1 ? 'last' : `${rule.nth as number}th`; 785 - return `Monthly, ${nth} ${dayNames[rule.weekday as number] ?? String(rule.weekday)} at ${timeStr} ${tz}`; 786 - } 787 - if (rule.type === 'monthly_last_business_day') { 788 - return `Monthly, last business day at ${timeStr} ${tz}`; 789 - } 790 - if (rule.type === 'yearly_on_month_day') { 791 - const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 792 - const monthName = monthNames[(rule.month as number) - 1] ?? String(rule.month); 793 - return `Yearly on ${monthName} ${rule.dayOfMonth as number} at ${timeStr} ${tz}`; 794 - } 795 - if (rule.type === 'yearly_nth_weekday') { 796 - const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 797 - const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 798 - const nth = (rule.nth as number) === -1 ? 'last' : `${rule.nth as number}th`; 799 - const monthName = monthNames[(rule.month as number) - 1] ?? String(rule.month); 800 - return `Yearly, ${nth} ${dayNames[rule.weekday as number] ?? String(rule.weekday)} of ${monthName} at ${timeStr} ${tz}`; 801 - } 802 - if (rule.type === 'quarterly_last_weekday') { 803 - const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 804 - return `Quarterly, last ${dayNames[rule.weekday as number] ?? String(rule.weekday)} at ${timeStr} ${tz}`; 805 - } 806 - if (rule.type === 'once') { 807 - return `Once at ${new Date(rule.datetime as string).toLocaleString()}`; 808 - } 809 - return `${rule.type as string} schedule`; 810 - } 811 765 812 766 function renderScheduleCard(schedule: Record<string, any>): string { 813 767 const status: string = schedule.status || 'active'; ··· 820 774 }; 821 775 const badgeCls = statusBadge[status] || 'badge-pending'; 822 776 823 - const rule = (schedule.recurrenceRule as Record<string, any>) || {}; 824 - const ruleCore = (rule.rule as Record<string, any>) || {}; 825 - const ruleDesc = describeRule(ruleCore, schedule.timezone as string); 777 + const ruleDesc = schedule.recurrenceRule 778 + ? formatRecurrenceRule(schedule.recurrenceRule as any) 779 + : (schedule.timezone as string) || 'unknown schedule'; 826 780 827 781 const fireCount: number = schedule.fireCount ?? 0; 828 782 const lastFired = schedule.lastFiredAt ··· 943 897 show('sched-nth-weekday-row', showNthWeekday); 944 898 } 945 899 900 + function applyRecurringDisabled(disabled: boolean): void { 901 + const notice = document.getElementById('sched-disabled-notice') as HTMLElement; 902 + const btn = document.getElementById('create-schedule-btn') as HTMLButtonElement; 903 + const nlpBtn = document.getElementById('sched-nlp-btn') as HTMLButtonElement; 904 + const nlpInput = document.getElementById('sched-nlp') as HTMLInputElement; 905 + if (disabled) { 906 + notice.classList.remove('hidden'); 907 + btn.disabled = true; 908 + nlpBtn.disabled = true; 909 + nlpInput.disabled = true; 910 + } else { 911 + notice.classList.add('hidden'); 912 + btn.disabled = false; 913 + nlpBtn.disabled = false; 914 + nlpInput.disabled = false; 915 + } 916 + } 917 + 946 918 function wireCreateScheduleForm(): void { 947 919 const typeSelect = document.getElementById('sched-type') as HTMLSelectElement; 948 920 const monthlyPatternSelect = document.getElementById('sched-monthly-pattern') as HTMLSelectElement; ··· 958 930 document.getElementById('create-schedule-btn')!.addEventListener('click', performCreateSchedule); 959 931 } 960 932 933 + function currentTimezone(): string { 934 + try { 935 + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; 936 + } catch (_) { 937 + return 'UTC'; 938 + } 939 + } 940 + 941 + function wireNlpInput(): void { 942 + const input = document.getElementById('sched-nlp') as HTMLInputElement; 943 + const btn = document.getElementById('sched-nlp-btn') as HTMLButtonElement; 944 + const feedback = document.getElementById('sched-nlp-feedback') as HTMLElement; 945 + 946 + function applyParsed(): void { 947 + const text = input.value.trim(); 948 + if (!text) return; 949 + 950 + const result = parseRecurrenceRule(text, currentTimezone()); 951 + 952 + if (!result) { 953 + feedback.textContent = "Couldn't parse — try: 'every Monday at 9am ET'"; 954 + feedback.style.color = '#ef4444'; 955 + feedback.classList.remove('hidden'); 956 + return; 957 + } 958 + 959 + const c = result.rule; 960 + 961 + // Map parsed rule type to form select value 962 + const typeSelect = document.getElementById('sched-type') as HTMLSelectElement; 963 + const intervalInput = document.getElementById('sched-interval') as HTMLInputElement; 964 + const hourInput = document.getElementById('sched-hour') as HTMLInputElement; 965 + const minuteInput = document.getElementById('sched-minute') as HTMLInputElement; 966 + const tzSelect = document.getElementById('sched-tz') as HTMLSelectElement; 967 + const monthlyPatternSelect = document.getElementById('sched-monthly-pattern') as HTMLSelectElement; 968 + const yearlyPatternSelect = document.getElementById('sched-yearly-pattern') as HTMLSelectElement; 969 + const nthSelect = document.getElementById('sched-nth') as HTMLSelectElement; 970 + const weekdaySelect = document.getElementById('sched-weekday') as HTMLSelectElement; 971 + const monthSelect = document.getElementById('sched-month') as HTMLSelectElement; 972 + const domInput = document.getElementById('sched-dom') as HTMLInputElement; 973 + 974 + // Resolve time spec (all rules except 'once' have a time field) 975 + const time = (c as any).time; 976 + 977 + // Set hour/minute/timezone 978 + if (time) { 979 + hourInput.value = String(time.hour ?? 9); 980 + minuteInput.value = String(time.minute ?? 0); 981 + // Try to set timezone — find matching option value 982 + const tz = time.timezone ?? 'UTC'; 983 + const tzOption = Array.from(tzSelect.options).find(o => o.value === tz); 984 + if (tzOption) { 985 + tzSelect.value = tz; 986 + } 987 + } 988 + 989 + // Set frequency type and sub-options 990 + if (c.type === 'daily') { 991 + typeSelect.value = 'daily'; 992 + intervalInput.value = String((c as any).interval ?? 1); 993 + } else if (c.type === 'weekly') { 994 + typeSelect.value = 'weekly'; 995 + intervalInput.value = String((c as any).interval ?? 1); 996 + // Check/uncheck weekday checkboxes 997 + const daysOfWeek: number[] = (c as any).daysOfWeek ?? []; 998 + document.querySelectorAll<HTMLInputElement>('input[name="sched-day"]').forEach(cb => { 999 + cb.checked = daysOfWeek.includes(parseInt(cb.value, 10)); 1000 + }); 1001 + } else if (c.type === 'monthly_on_day') { 1002 + typeSelect.value = 'monthly'; 1003 + monthlyPatternSelect.value = 'on_day'; 1004 + intervalInput.value = String((c as any).interval ?? 1); 1005 + domInput.value = String((c as any).dayOfMonth ?? 1); 1006 + } else if (c.type === 'monthly_nth_weekday') { 1007 + typeSelect.value = 'monthly'; 1008 + monthlyPatternSelect.value = 'nth_weekday'; 1009 + intervalInput.value = String((c as any).interval ?? 1); 1010 + nthSelect.value = String((c as any).nth ?? 1); 1011 + weekdaySelect.value = String((c as any).weekday ?? 1); 1012 + } else if (c.type === 'monthly_last_business_day') { 1013 + typeSelect.value = 'monthly'; 1014 + monthlyPatternSelect.value = 'last_business_day'; 1015 + intervalInput.value = String((c as any).interval ?? 1); 1016 + } else if (c.type === 'quarterly_last_weekday') { 1017 + typeSelect.value = 'quarterly'; 1018 + weekdaySelect.value = String((c as any).weekday ?? 5); 1019 + } else if (c.type === 'yearly_on_month_day') { 1020 + typeSelect.value = 'yearly'; 1021 + yearlyPatternSelect.value = 'on_month_day'; 1022 + monthSelect.value = String((c as any).month ?? 1); 1023 + domInput.value = String((c as any).dayOfMonth ?? 1); 1024 + } else if (c.type === 'yearly_nth_weekday') { 1025 + typeSelect.value = 'yearly'; 1026 + yearlyPatternSelect.value = 'nth_weekday'; 1027 + monthSelect.value = String((c as any).month ?? 1); 1028 + nthSelect.value = String((c as any).nth ?? 1); 1029 + weekdaySelect.value = String((c as any).weekday ?? 1); 1030 + } 1031 + 1032 + // Refresh visibility of sub-options 1033 + updateScheduleFormVisibility(); 1034 + 1035 + // Build human-readable summary for the feedback line 1036 + feedback.textContent = `Parsed: ${formatRecurrenceRule(result)}`; 1037 + feedback.style.color = '#166534'; 1038 + feedback.classList.remove('hidden'); 1039 + } 1040 + 1041 + btn.addEventListener('click', applyParsed); 1042 + input.addEventListener('keydown', (e) => { 1043 + if (e.key === 'Enter') applyParsed(); 1044 + }); 1045 + } 1046 + 961 1047 async function performCreateSchedule(): Promise<void> { 962 1048 const btn = document.getElementById('create-schedule-btn') as HTMLButtonElement; 963 1049 const successEl = document.getElementById('sched-success') as HTMLElement; ··· 1197 1283 wireImagePicker(); 1198 1284 wireScheduleButton(); 1199 1285 wireCreateScheduleForm(); 1286 + wireNlpInput(); 1200 1287 wireReauth(); 1201 1288 wireSignOut(); 1202 1289 wireDeleteAccount();
+12
demo/public/index.html
··· 814 814 815 815 <!-- Recurring section (Recurring tab only) --> 816 816 <div id="recurring-section" class="hidden"> 817 + <div id="sched-disabled-notice" class="banner banner-warn hidden"> 818 + Recurring schedules are disabled on this demo server to prevent abuse. The interface is shown for preview only. 819 + </div> 820 + <div style="margin-bottom:1rem;"> 821 + <label for="sched-nlp">Describe your schedule <span style="font-weight:400;color:var(--text-faint);">(optional)</span></label> 822 + <div style="display:flex;gap:0.5rem;"> 823 + <input id="sched-nlp" type="text" placeholder="e.g. every Monday at 9am ET" style="flex:1;margin-bottom:0;" /> 824 + <button id="sched-nlp-btn" type="button" class="btn btn-secondary" style="flex-shrink:0;white-space:nowrap;">Parse</button> 825 + </div> 826 + <p id="sched-nlp-feedback" class="hidden" style="font-size:0.78rem;margin-top:0.4rem;"></p> 827 + </div> 828 + 817 829 <label for="sched-text">Post text</label> 818 830 <textarea id="sched-text" placeholder="Text posted on each occurrence…" style="min-height:60px;"></textarea> 819 831
+33
docs/api.md
··· 659 659 | `scheduledAt` | ISO 8601 datetime of the scheduled occurrence | 660 660 661 661 The response must be a JSON object that is the record to publish. 662 + 663 + --- 664 + 665 + ## Client utilities (`@newpublic/recurrence`) 666 + 667 + The recurrence package exported by the monorepo includes two utilities for working with `RecurrenceRule` objects on the client side. 668 + 669 + ### `parseRecurrenceRule(input, defaultTimezone?)` 670 + 671 + Parses a plain-English string into a `RecurrenceRule`. Returns `null` if the input doesn't match any known pattern. 672 + 673 + ```typescript 674 + import { parseRecurrenceRule } from '@newpublic/recurrence'; 675 + 676 + parseRecurrenceRule('every Monday and Friday at 9am ET'); 677 + parseRecurrenceRule('last business day of each month at 5pm UTC'); 678 + parseRecurrenceRule('every year on January 1st at midnight', 'America/New_York'); 679 + ``` 680 + 681 + `defaultTimezone` is an IANA timezone used when no timezone is detected in the input; defaults to `'UTC'`. See the [package README](../packages/recurrence/README.md) for the full list of supported phrasings. 682 + 683 + ### `formatRecurrenceRule(rule)` 684 + 685 + Formats a `RecurrenceRule` as a human-readable English string. Useful for displaying schedule summaries in UI. 686 + 687 + ```typescript 688 + import { formatRecurrenceRule } from '@newpublic/recurrence'; 689 + 690 + formatRecurrenceRule(rule); 691 + // e.g. "every Monday and Friday at 9am (ET)" 692 + // e.g. "last business day of every month at 5pm (UTC)" 693 + // e.g. "every year on January 1st at midnight (UTC), starting 2026-01-01" 694 + ```
+73
packages/recurrence/README.md
··· 1 + # @newpublic/recurrence 2 + 3 + Recurrence rule engine for ALF scheduled posts. Handles computing next occurrence dates, parsing plain-English schedule descriptions, and formatting rules back into readable strings. 4 + 5 + ## API 6 + 7 + ### `computeNextOccurrence(rule, after)` 8 + 9 + Returns the next `Date` on or after `after` that matches the rule, or `null` if the rule has expired (past `endDate` or exhausted `count`). 10 + 11 + ```typescript 12 + import { computeNextOccurrence } from '@newpublic/recurrence'; 13 + 14 + const next = computeNextOccurrence(rule, new Date()); 15 + ``` 16 + 17 + ### `parseRecurrenceRule(input, defaultTimezone?)` 18 + 19 + Parses a plain-English string into a `RecurrenceRule`. Returns `null` if the input doesn't match a known pattern. 20 + 21 + ```typescript 22 + import { parseRecurrenceRule } from '@newpublic/recurrence'; 23 + 24 + parseRecurrenceRule('every Monday and Friday at 9am ET'); 25 + // → { rule: { type: 'weekly', daysOfWeek: [1, 5], time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'America/New_York' } } } 26 + 27 + parseRecurrenceRule('last business day of each month at 5pm UTC'); 28 + // → { rule: { type: 'monthly_last_business_day', time: { ... } } } 29 + 30 + parseRecurrenceRule('gibberish'); // → null 31 + ``` 32 + 33 + `defaultTimezone` is an IANA timezone string used when the input contains no timezone. Defaults to `'UTC'`. 34 + 35 + **Supported phrasings (examples):** 36 + 37 + | Rule type | Example inputs | 38 + |-----------|---------------| 39 + | `daily` | "every day at 9am", "daily at noon", "every 3 days at 8am" | 40 + | `weekly` | "every Monday", "every Monday and Friday at 9am ET", "weekdays at 9am", "weekends at noon", "every 2 weeks on Tuesday" | 41 + | `monthly_on_day` | "every month on the 15th", "1st of each month at noon", "monthly on the 28th" | 42 + | `monthly_nth_weekday` | "first Monday of each month", "last Friday of every month", "3rd Wednesday of each month" | 43 + | `monthly_last_business_day` | "last business day", "last business day of each month", "last weekday of the month" | 44 + | `quarterly_last_weekday` | "last Friday of each quarter", "quarterly last Thursday at 10am" | 45 + | `yearly_on_month_day` | "every year on January 1st", "March 15th each year", "annually on July 4th" | 46 + | `yearly_nth_weekday` | "last Friday of December every year", "first Monday of March yearly" | 47 + 48 + **End conditions** can be appended to any phrase: 49 + - `"every Monday at 9am for 10 times"` → `count: 10` 50 + - `"every day at noon until 2026-12-31"` → `endDate: '2026-12-31'` 51 + - `"every week on Friday starting 2026-04-01"` → `startDate: '2026-04-01'` 52 + 53 + **Timezone recognition:** common abbreviations (`ET`, `CT`, `MT`, `PT`, `UTC`, `GMT`, `BST`, `CET`, `JST`, `AEST`) and raw IANA strings (`America/New_York`) are detected in the input text. 54 + 55 + ### `formatRecurrenceRule(rule)` 56 + 57 + Formats a `RecurrenceRule` as a human-readable English string. 58 + 59 + ```typescript 60 + import { formatRecurrenceRule } from '@newpublic/recurrence'; 61 + 62 + formatRecurrenceRule({ rule: { type: 'weekly', daysOfWeek: [1, 5], time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'America/New_York' } } }); 63 + // → "every Monday and Friday at 9am (ET)" 64 + 65 + formatRecurrenceRule({ rule: { type: 'monthly_last_business_day', time: { type: 'wall_time', hour: 17, minute: 0, timezone: 'UTC' } } }); 66 + // → "last business day of every month at 5pm (UTC)" 67 + ``` 68 + 69 + Known IANA timezones are displayed as short labels (`ET`, `CT`, `MT`, `PT`, `JST`, etc.). Unknown IANA strings are shown verbatim. `fixed_instant` offsets are formatted as `UTC±N`. 70 + 71 + ## Rule types 72 + 73 + See [`src/types.ts`](src/types.ts) for the full TypeScript type definitions, or the [ALF API reference](../../docs/api.md#recurrencerule-object) for a narrative description of all rule types with examples.
+230
packages/recurrence/src/__tests__/formatter.test.ts
··· 1 + // ABOUTME: Tests for the recurrence rule formatter 2 + 3 + import { formatRecurrenceRule } from '../formatter'; 4 + import type { RecurrenceRule } from '../types'; 5 + 6 + const UTC: import('../types').WallTime = { type: 'wall_time', hour: 9, minute: 0, timezone: 'UTC' }; 7 + const ET: import('../types').WallTime = { type: 'wall_time', hour: 9, minute: 0, timezone: 'America/New_York' }; 8 + 9 + function r(rule: RecurrenceRule['rule'], extras?: Partial<RecurrenceRule>): RecurrenceRule { 10 + return { rule, ...extras }; 11 + } 12 + 13 + // --------------------------------------------------------------------------- 14 + // Daily 15 + // --------------------------------------------------------------------------- 16 + 17 + describe('formatRecurrenceRule — daily', () => { 18 + it('every day', () => { 19 + expect(formatRecurrenceRule(r({ type: 'daily', time: UTC }))).toBe('every day at 9am (UTC)'); 20 + }); 21 + 22 + it('every other day', () => { 23 + expect(formatRecurrenceRule(r({ type: 'daily', interval: 2, time: UTC }))).toBe('every other day at 9am (UTC)'); 24 + }); 25 + 26 + it('every 3 days', () => { 27 + expect(formatRecurrenceRule(r({ type: 'daily', interval: 3, time: UTC }))).toBe('every 3 days at 9am (UTC)'); 28 + }); 29 + 30 + it('noon', () => { 31 + expect(formatRecurrenceRule(r({ type: 'daily', time: { type: 'wall_time', hour: 12, minute: 0, timezone: 'UTC' } }))).toBe('every day at noon (UTC)'); 32 + }); 33 + 34 + it('midnight', () => { 35 + expect(formatRecurrenceRule(r({ type: 'daily', time: { type: 'wall_time', hour: 0, minute: 0, timezone: 'UTC' } }))).toBe('every day at midnight (UTC)'); 36 + }); 37 + 38 + it('5pm ET', () => { 39 + expect(formatRecurrenceRule(r({ type: 'daily', time: { type: 'wall_time', hour: 17, minute: 0, timezone: 'America/New_York' } }))).toBe('every day at 5pm (ET)'); 40 + }); 41 + 42 + it('8:30am', () => { 43 + expect(formatRecurrenceRule(r({ type: 'daily', time: { type: 'wall_time', hour: 8, minute: 30, timezone: 'UTC' } }))).toBe('every day at 8:30am (UTC)'); 44 + }); 45 + }); 46 + 47 + // --------------------------------------------------------------------------- 48 + // Weekly 49 + // --------------------------------------------------------------------------- 50 + 51 + describe('formatRecurrenceRule — weekly', () => { 52 + it('every weekday', () => { 53 + expect(formatRecurrenceRule(r({ type: 'weekly', daysOfWeek: [1, 2, 3, 4, 5], time: UTC }))).toBe('every weekday at 9am (UTC)'); 54 + }); 55 + 56 + it('every weekend', () => { 57 + expect(formatRecurrenceRule(r({ type: 'weekly', daysOfWeek: [0, 6], time: UTC }))).toBe('every weekend at 9am (UTC)'); 58 + }); 59 + 60 + it('single day', () => { 61 + expect(formatRecurrenceRule(r({ type: 'weekly', daysOfWeek: [1], time: ET }))).toBe('every Monday at 9am (ET)'); 62 + }); 63 + 64 + it('two days', () => { 65 + expect(formatRecurrenceRule(r({ type: 'weekly', daysOfWeek: [1, 5], time: UTC }))).toBe('every Monday and Friday at 9am (UTC)'); 66 + }); 67 + 68 + it('three days', () => { 69 + expect(formatRecurrenceRule(r({ type: 'weekly', daysOfWeek: [1, 3, 5], time: UTC }))).toBe('every Monday, Wednesday and Friday at 9am (UTC)'); 70 + }); 71 + 72 + it('every 2 weeks on Tuesday', () => { 73 + expect(formatRecurrenceRule(r({ type: 'weekly', interval: 2, daysOfWeek: [2], time: UTC }))).toBe('every other week on Tuesday at 9am (UTC)'); 74 + }); 75 + 76 + it('every 3 weeks on Wednesday', () => { 77 + expect(formatRecurrenceRule(r({ type: 'weekly', interval: 3, daysOfWeek: [3], time: UTC }))).toBe('every 3 weeks on Wednesday at 9am (UTC)'); 78 + }); 79 + }); 80 + 81 + // --------------------------------------------------------------------------- 82 + // Monthly on day 83 + // --------------------------------------------------------------------------- 84 + 85 + describe('formatRecurrenceRule — monthly_on_day', () => { 86 + it('every month on the 1st', () => { 87 + expect(formatRecurrenceRule(r({ type: 'monthly_on_day', dayOfMonth: 1, time: UTC }))).toBe('every month on the 1st at 9am (UTC)'); 88 + }); 89 + 90 + it('every month on the 15th', () => { 91 + expect(formatRecurrenceRule(r({ type: 'monthly_on_day', dayOfMonth: 15, time: UTC }))).toBe('every month on the 15th at 9am (UTC)'); 92 + }); 93 + 94 + it('every month on the 22nd', () => { 95 + expect(formatRecurrenceRule(r({ type: 'monthly_on_day', dayOfMonth: 22, time: UTC }))).toBe('every month on the 22nd at 9am (UTC)'); 96 + }); 97 + 98 + it('every other month on the 3rd', () => { 99 + expect(formatRecurrenceRule(r({ type: 'monthly_on_day', interval: 2, dayOfMonth: 3, time: UTC }))).toBe('every other month on the 3rd at 9am (UTC)'); 100 + }); 101 + 102 + it('every 3 months on the 1st', () => { 103 + expect(formatRecurrenceRule(r({ type: 'monthly_on_day', interval: 3, dayOfMonth: 1, time: UTC }))).toBe('every 3 months on the 1st at 9am (UTC)'); 104 + }); 105 + }); 106 + 107 + // --------------------------------------------------------------------------- 108 + // Monthly nth weekday 109 + // --------------------------------------------------------------------------- 110 + 111 + describe('formatRecurrenceRule — monthly_nth_weekday', () => { 112 + it('first Monday of every month', () => { 113 + expect(formatRecurrenceRule(r({ type: 'monthly_nth_weekday', nth: 1, weekday: 1, time: UTC }))).toBe('1st Monday of every month at 9am (UTC)'); 114 + }); 115 + 116 + it('last Friday of every month', () => { 117 + expect(formatRecurrenceRule(r({ type: 'monthly_nth_weekday', nth: -1, weekday: 5, time: UTC }))).toBe('last Friday of every month at 9am (UTC)'); 118 + }); 119 + 120 + it('3rd Wednesday of every month', () => { 121 + expect(formatRecurrenceRule(r({ type: 'monthly_nth_weekday', nth: 3, weekday: 3, time: UTC }))).toBe('3rd Wednesday of every month at 9am (UTC)'); 122 + }); 123 + 124 + it('last Tuesday of every other month', () => { 125 + expect(formatRecurrenceRule(r({ type: 'monthly_nth_weekday', interval: 2, nth: -1, weekday: 2, time: UTC }))).toBe('last Tuesday of every other month at 9am (UTC)'); 126 + }); 127 + }); 128 + 129 + // --------------------------------------------------------------------------- 130 + // Monthly last business day 131 + // --------------------------------------------------------------------------- 132 + 133 + describe('formatRecurrenceRule — monthly_last_business_day', () => { 134 + it('last business day of every month', () => { 135 + expect(formatRecurrenceRule(r({ type: 'monthly_last_business_day', time: UTC }))).toBe('last business day of every month at 9am (UTC)'); 136 + }); 137 + 138 + it('last business day of every other month', () => { 139 + expect(formatRecurrenceRule(r({ type: 'monthly_last_business_day', interval: 2, time: UTC }))).toBe('last business day of every other month at 9am (UTC)'); 140 + }); 141 + }); 142 + 143 + // --------------------------------------------------------------------------- 144 + // Quarterly last weekday 145 + // --------------------------------------------------------------------------- 146 + 147 + describe('formatRecurrenceRule — quarterly_last_weekday', () => { 148 + it('last Friday of every quarter', () => { 149 + expect(formatRecurrenceRule(r({ type: 'quarterly_last_weekday', weekday: 5, time: UTC }))).toBe('last Friday of every quarter at 9am (UTC)'); 150 + }); 151 + 152 + it('last Monday of every quarter', () => { 153 + expect(formatRecurrenceRule(r({ type: 'quarterly_last_weekday', weekday: 1, time: UTC }))).toBe('last Monday of every quarter at 9am (UTC)'); 154 + }); 155 + }); 156 + 157 + // --------------------------------------------------------------------------- 158 + // Yearly 159 + // --------------------------------------------------------------------------- 160 + 161 + describe('formatRecurrenceRule — yearly', () => { 162 + it('every year on January 1st', () => { 163 + expect(formatRecurrenceRule(r({ type: 'yearly_on_month_day', month: 1, dayOfMonth: 1, time: UTC }))).toBe('every year on January 1st at 9am (UTC)'); 164 + }); 165 + 166 + it('every year on March 15th', () => { 167 + expect(formatRecurrenceRule(r({ type: 'yearly_on_month_day', month: 3, dayOfMonth: 15, time: UTC }))).toBe('every year on March 15th at 9am (UTC)'); 168 + }); 169 + 170 + it('last Friday of December every year', () => { 171 + expect(formatRecurrenceRule(r({ type: 'yearly_nth_weekday', month: 12, nth: -1, weekday: 5, time: UTC }))).toBe('last Friday of December every year at 9am (UTC)'); 172 + }); 173 + 174 + it('first Monday of March every year', () => { 175 + expect(formatRecurrenceRule(r({ type: 'yearly_nth_weekday', month: 3, nth: 1, weekday: 1, time: UTC }))).toBe('1st Monday of March every year at 9am (UTC)'); 176 + }); 177 + }); 178 + 179 + // --------------------------------------------------------------------------- 180 + // End conditions 181 + // --------------------------------------------------------------------------- 182 + 183 + describe('formatRecurrenceRule — end conditions', () => { 184 + it('appends startDate', () => { 185 + const result = formatRecurrenceRule(r({ type: 'daily', time: UTC }, { startDate: '2026-04-01' })); 186 + expect(result).toBe('every day at 9am (UTC), starting 2026-04-01'); 187 + }); 188 + 189 + it('appends endDate', () => { 190 + const result = formatRecurrenceRule(r({ type: 'daily', time: UTC }, { endDate: '2026-12-31' })); 191 + expect(result).toBe('every day at 9am (UTC), until 2026-12-31'); 192 + }); 193 + 194 + it('appends count', () => { 195 + const result = formatRecurrenceRule(r({ type: 'weekly', daysOfWeek: [1], time: UTC }, { count: 10 })); 196 + expect(result).toBe('every Monday at 9am (UTC), for 10 occurrences'); 197 + }); 198 + 199 + it('singular occurrence', () => { 200 + const result = formatRecurrenceRule(r({ type: 'daily', time: UTC }, { count: 1 })); 201 + expect(result).toBe('every day at 9am (UTC), for 1 occurrence'); 202 + }); 203 + }); 204 + 205 + // --------------------------------------------------------------------------- 206 + // Timezone display 207 + // --------------------------------------------------------------------------- 208 + 209 + describe('formatRecurrenceRule — timezone display', () => { 210 + it('America/New_York → ET', () => { 211 + expect(formatRecurrenceRule(r({ type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'America/New_York' } }))).toContain('(ET)'); 212 + }); 213 + 214 + it('America/Chicago → CT', () => { 215 + expect(formatRecurrenceRule(r({ type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'America/Chicago' } }))).toContain('(CT)'); 216 + }); 217 + 218 + it('America/Los_Angeles → PT', () => { 219 + expect(formatRecurrenceRule(r({ type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'America/Los_Angeles' } }))).toContain('(PT)'); 220 + }); 221 + 222 + it('unknown IANA → shown as-is', () => { 223 + expect(formatRecurrenceRule(r({ type: 'daily', time: { type: 'wall_time', hour: 9, minute: 0, timezone: 'America/Phoenix' } }))).toContain('(America/Phoenix)'); 224 + }); 225 + 226 + it('fixed_instant UTC offset', () => { 227 + const result = formatRecurrenceRule(r({ type: 'daily', time: { type: 'fixed_instant', utcOffsetMinutes: -300, hour: 9, minute: 0 } })); 228 + expect(result).toContain('(UTC-5)'); 229 + }); 230 + });
+451
packages/recurrence/src/__tests__/parser.test.ts
··· 1 + // ABOUTME: Tests for the natural language recurrence rule parser 2 + 3 + import { parseRecurrenceRule } from '../parser'; 4 + 5 + // Shorthand: get the core rule object with an any cast for property access 6 + // (TypeScript can't narrow a union type across separate function calls) 7 + function core(r: ReturnType<typeof parseRecurrenceRule>): any { 8 + return r!.rule; 9 + } 10 + 11 + // --------------------------------------------------------------------------- 12 + // Daily rules 13 + // --------------------------------------------------------------------------- 14 + 15 + describe('parseRecurrenceRule — daily', () => { 16 + it('parses "every day"', () => { 17 + const r = parseRecurrenceRule('every day at 9am'); 18 + expect(r).not.toBeNull(); 19 + expect(core(r).type).toBe('daily'); 20 + }); 21 + 22 + it('parses "daily"', () => { 23 + const r = parseRecurrenceRule('daily at noon'); 24 + expect(r).not.toBeNull(); 25 + expect(core(r).type).toBe('daily'); 26 + }); 27 + 28 + it('parses "every 3 days"', () => { 29 + const r = parseRecurrenceRule('every 3 days at 8am'); 30 + expect(r).not.toBeNull(); 31 + expect(core(r).type).toBe('daily'); 32 + expect(core(r).interval).toBe(3); 33 + }); 34 + 35 + it('parses "every other day" as interval 2', () => { 36 + const r = parseRecurrenceRule('every other day at 10am'); 37 + expect(r).not.toBeNull(); 38 + expect(core(r).type).toBe('daily'); 39 + expect(core(r).interval).toBe(2); 40 + }); 41 + 42 + it('parses "each day" as daily', () => { 43 + const r = parseRecurrenceRule('each day at 6pm'); 44 + expect(r).not.toBeNull(); 45 + expect(core(r).type).toBe('daily'); 46 + }); 47 + }); 48 + 49 + // --------------------------------------------------------------------------- 50 + // Weekly rules 51 + // --------------------------------------------------------------------------- 52 + 53 + describe('parseRecurrenceRule — weekly', () => { 54 + it('parses "every Monday"', () => { 55 + const r = parseRecurrenceRule('every Monday at 9am'); 56 + expect(r).not.toBeNull(); 57 + expect(core(r).type).toBe('weekly'); 58 + expect(core(r).daysOfWeek).toEqual([1]); 59 + }); 60 + 61 + it('parses "every Monday and Friday"', () => { 62 + const r = parseRecurrenceRule('every Monday and Friday at 9am ET'); 63 + expect(r).not.toBeNull(); 64 + expect(core(r).type).toBe('weekly'); 65 + expect(core(r).daysOfWeek).toEqual([1, 5]); 66 + }); 67 + 68 + it('parses "weekdays" as Mon-Fri', () => { 69 + const r = parseRecurrenceRule('every weekday at 9am'); 70 + expect(r).not.toBeNull(); 71 + expect(core(r).type).toBe('weekly'); 72 + expect(core(r).daysOfWeek).toEqual([1, 2, 3, 4, 5]); 73 + }); 74 + 75 + it('parses "weekends" as Sat+Sun', () => { 76 + const r = parseRecurrenceRule('weekends at noon'); 77 + expect(r).not.toBeNull(); 78 + expect(core(r).type).toBe('weekly'); 79 + expect(core(r).daysOfWeek).toEqual([0, 6]); 80 + }); 81 + 82 + it('parses "every 2 weeks on Tuesday"', () => { 83 + const r = parseRecurrenceRule('every 2 weeks on Tuesday at 10am'); 84 + expect(r).not.toBeNull(); 85 + expect(core(r).type).toBe('weekly'); 86 + expect(core(r).interval).toBe(2); 87 + expect(core(r).daysOfWeek).toEqual([2]); 88 + }); 89 + 90 + it('parses "every other week on Monday"', () => { 91 + const r = parseRecurrenceRule('every other week on Monday at 9am'); 92 + expect(r).not.toBeNull(); 93 + expect(core(r).type).toBe('weekly'); 94 + expect(core(r).interval).toBe(2); 95 + }); 96 + 97 + it('parses multiple days: Mon, Wed, Fri', () => { 98 + const r = parseRecurrenceRule('every Monday, Wednesday, Friday at 8am'); 99 + expect(r).not.toBeNull(); 100 + expect(core(r).type).toBe('weekly'); 101 + expect(core(r).daysOfWeek).toEqual([1, 3, 5]); 102 + }); 103 + }); 104 + 105 + // --------------------------------------------------------------------------- 106 + // Monthly on day 107 + // --------------------------------------------------------------------------- 108 + 109 + describe('parseRecurrenceRule — monthly_on_day', () => { 110 + it('parses "every month on the 15th"', () => { 111 + const r = parseRecurrenceRule('every month on the 15th at 9am'); 112 + expect(r).not.toBeNull(); 113 + expect(core(r).type).toBe('monthly_on_day'); 114 + expect(core(r).dayOfMonth).toBe(15); 115 + }); 116 + 117 + it('parses "1st of each month"', () => { 118 + const r = parseRecurrenceRule('1st of each month at noon'); 119 + expect(r).not.toBeNull(); 120 + expect(core(r).type).toBe('monthly_on_day'); 121 + expect(core(r).dayOfMonth).toBe(1); 122 + }); 123 + 124 + it('parses "monthly on the 28th"', () => { 125 + const r = parseRecurrenceRule('monthly on the 28th at 5pm'); 126 + expect(r).not.toBeNull(); 127 + expect(core(r).type).toBe('monthly_on_day'); 128 + expect(core(r).dayOfMonth).toBe(28); 129 + }); 130 + }); 131 + 132 + // --------------------------------------------------------------------------- 133 + // Monthly nth weekday 134 + // --------------------------------------------------------------------------- 135 + 136 + describe('parseRecurrenceRule — monthly_nth_weekday', () => { 137 + it('parses "first Monday of each month"', () => { 138 + const r = parseRecurrenceRule('first Monday of each month at 9am'); 139 + expect(r).not.toBeNull(); 140 + expect(core(r).type).toBe('monthly_nth_weekday'); 141 + expect(core(r).nth).toBe(1); 142 + expect(core(r).weekday).toBe(1); 143 + }); 144 + 145 + it('parses "last Friday of every month"', () => { 146 + const r = parseRecurrenceRule('last Friday of every month at 5pm'); 147 + expect(r).not.toBeNull(); 148 + expect(core(r).type).toBe('monthly_nth_weekday'); 149 + expect(core(r).nth).toBe(-1); 150 + expect(core(r).weekday).toBe(5); 151 + }); 152 + 153 + it('parses "3rd Wednesday of each month"', () => { 154 + const r = parseRecurrenceRule('3rd Wednesday of each month at 10am'); 155 + expect(r).not.toBeNull(); 156 + expect(core(r).type).toBe('monthly_nth_weekday'); 157 + expect(core(r).nth).toBe(3); 158 + expect(core(r).weekday).toBe(3); 159 + }); 160 + 161 + it('parses "second Tuesday of the month"', () => { 162 + const r = parseRecurrenceRule('second Tuesday of the month at 2pm'); 163 + expect(r).not.toBeNull(); 164 + expect(core(r).type).toBe('monthly_nth_weekday'); 165 + expect(core(r).nth).toBe(2); 166 + expect(core(r).weekday).toBe(2); 167 + }); 168 + }); 169 + 170 + // --------------------------------------------------------------------------- 171 + // Monthly last business day 172 + // --------------------------------------------------------------------------- 173 + 174 + describe('parseRecurrenceRule — monthly_last_business_day', () => { 175 + it('parses "last business day of each month"', () => { 176 + const r = parseRecurrenceRule('last business day of each month at 5pm UTC'); 177 + expect(r).not.toBeNull(); 178 + expect(core(r).type).toBe('monthly_last_business_day'); 179 + }); 180 + 181 + it('parses "last business day"', () => { 182 + const r = parseRecurrenceRule('last business day at noon'); 183 + expect(r).not.toBeNull(); 184 + expect(core(r).type).toBe('monthly_last_business_day'); 185 + }); 186 + 187 + it('parses "last weekday of the month"', () => { 188 + const r = parseRecurrenceRule('last weekday of the month at 4pm'); 189 + expect(r).not.toBeNull(); 190 + expect(core(r).type).toBe('monthly_last_business_day'); 191 + }); 192 + }); 193 + 194 + // --------------------------------------------------------------------------- 195 + // Quarterly last weekday 196 + // --------------------------------------------------------------------------- 197 + 198 + describe('parseRecurrenceRule — quarterly_last_weekday', () => { 199 + it('parses "last Friday of each quarter"', () => { 200 + const r = parseRecurrenceRule('last Friday of each quarter at 3pm'); 201 + expect(r).not.toBeNull(); 202 + expect(core(r).type).toBe('quarterly_last_weekday'); 203 + expect(core(r).weekday).toBe(5); 204 + }); 205 + 206 + it('parses "last Monday of every quarter"', () => { 207 + const r = parseRecurrenceRule('last Monday of every quarter at 9am'); 208 + expect(r).not.toBeNull(); 209 + expect(core(r).type).toBe('quarterly_last_weekday'); 210 + expect(core(r).weekday).toBe(1); 211 + }); 212 + 213 + it('parses "quarterly last Thursday"', () => { 214 + const r = parseRecurrenceRule('quarterly last Thursday at 10am'); 215 + expect(r).not.toBeNull(); 216 + expect(core(r).type).toBe('quarterly_last_weekday'); 217 + expect(core(r).weekday).toBe(4); 218 + }); 219 + }); 220 + 221 + // --------------------------------------------------------------------------- 222 + // Yearly rules 223 + // --------------------------------------------------------------------------- 224 + 225 + describe('parseRecurrenceRule — yearly', () => { 226 + it('parses "every year on January 1st"', () => { 227 + const r = parseRecurrenceRule('every year on January 1st at midnight'); 228 + expect(r).not.toBeNull(); 229 + expect(core(r).type).toBe('yearly_on_month_day'); 230 + expect(core(r).month).toBe(1); 231 + expect(core(r).dayOfMonth).toBe(1); 232 + }); 233 + 234 + it('parses "March 15th each year"', () => { 235 + const r = parseRecurrenceRule('March 15th each year at 9am'); 236 + expect(r).not.toBeNull(); 237 + expect(core(r).type).toBe('yearly_on_month_day'); 238 + expect(core(r).month).toBe(3); 239 + expect(core(r).dayOfMonth).toBe(15); 240 + }); 241 + 242 + it('parses "annually on July 4th"', () => { 243 + const r = parseRecurrenceRule('annually on July 4th at noon'); 244 + expect(r).not.toBeNull(); 245 + expect(core(r).type).toBe('yearly_on_month_day'); 246 + expect(core(r).month).toBe(7); 247 + expect(core(r).dayOfMonth).toBe(4); 248 + }); 249 + 250 + it('parses "last Friday of December every year"', () => { 251 + const r = parseRecurrenceRule('last Friday of December every year at 5pm'); 252 + expect(r).not.toBeNull(); 253 + expect(core(r).type).toBe('yearly_nth_weekday'); 254 + expect(core(r).month).toBe(12); 255 + expect(core(r).nth).toBe(-1); 256 + expect(core(r).weekday).toBe(5); 257 + }); 258 + 259 + it('parses "first Monday of March yearly"', () => { 260 + const r = parseRecurrenceRule('first Monday of March yearly at 8am'); 261 + expect(r).not.toBeNull(); 262 + expect(core(r).type).toBe('yearly_nth_weekday'); 263 + expect(core(r).month).toBe(3); 264 + expect(core(r).nth).toBe(1); 265 + expect(core(r).weekday).toBe(1); 266 + }); 267 + }); 268 + 269 + // --------------------------------------------------------------------------- 270 + // Timezone extraction 271 + // --------------------------------------------------------------------------- 272 + 273 + describe('parseRecurrenceRule — timezone extraction', () => { 274 + it('extracts ET → America/New_York', () => { 275 + const r = parseRecurrenceRule('every Monday at 9am ET'); 276 + expect(r).not.toBeNull(); 277 + expect(core(r).time.timezone).toBe('America/New_York'); 278 + }); 279 + 280 + it('extracts EST → America/New_York', () => { 281 + const r = parseRecurrenceRule('every weekday at 8am EST'); 282 + expect(r).not.toBeNull(); 283 + expect(core(r).time.timezone).toBe('America/New_York'); 284 + }); 285 + 286 + it('extracts PT → America/Los_Angeles', () => { 287 + const r = parseRecurrenceRule('daily at 6pm PT'); 288 + expect(r).not.toBeNull(); 289 + expect(core(r).time.timezone).toBe('America/Los_Angeles'); 290 + }); 291 + 292 + it('extracts UTC', () => { 293 + const r = parseRecurrenceRule('every day at noon UTC'); 294 + expect(r).not.toBeNull(); 295 + expect(core(r).time.timezone).toBe('UTC'); 296 + }); 297 + 298 + it('extracts IANA timezone literal', () => { 299 + const r = parseRecurrenceRule('every Monday at 9am America/Chicago'); 300 + expect(r).not.toBeNull(); 301 + expect(core(r).time.timezone).toBe('America/Chicago'); 302 + }); 303 + 304 + it('uses defaultTimezone when no timezone mentioned', () => { 305 + const r = parseRecurrenceRule('every Tuesday at 10am', 'America/Denver'); 306 + expect(r).not.toBeNull(); 307 + expect(core(r).time.timezone).toBe('America/Denver'); 308 + }); 309 + 310 + it('falls back to UTC when no timezone and no default', () => { 311 + const r = parseRecurrenceRule('every Wednesday at 2pm'); 312 + expect(r).not.toBeNull(); 313 + expect(core(r).time.timezone).toBe('UTC'); 314 + }); 315 + }); 316 + 317 + // --------------------------------------------------------------------------- 318 + // Time parsing 319 + // --------------------------------------------------------------------------- 320 + 321 + describe('parseRecurrenceRule — time parsing', () => { 322 + it('parses 12-hour am', () => { 323 + const r = parseRecurrenceRule('every day at 9am'); 324 + expect(r).not.toBeNull(); 325 + expect(core(r).time.hour).toBe(9); 326 + expect(core(r).time.minute).toBe(0); 327 + }); 328 + 329 + it('parses 12-hour pm', () => { 330 + const r = parseRecurrenceRule('every day at 5pm'); 331 + expect(r).not.toBeNull(); 332 + expect(core(r).time.hour).toBe(17); 333 + expect(core(r).time.minute).toBe(0); 334 + }); 335 + 336 + it('parses noon', () => { 337 + const r = parseRecurrenceRule('every day at noon'); 338 + expect(r).not.toBeNull(); 339 + expect(core(r).time.hour).toBe(12); 340 + expect(core(r).time.minute).toBe(0); 341 + }); 342 + 343 + it('parses midnight', () => { 344 + const r = parseRecurrenceRule('every day at midnight'); 345 + expect(r).not.toBeNull(); 346 + expect(core(r).time.hour).toBe(0); 347 + expect(core(r).time.minute).toBe(0); 348 + }); 349 + 350 + it('parses H:MM am', () => { 351 + const r = parseRecurrenceRule('every day at 8:30am'); 352 + expect(r).not.toBeNull(); 353 + expect(core(r).time.hour).toBe(8); 354 + expect(core(r).time.minute).toBe(30); 355 + }); 356 + 357 + it('parses 12pm as noon', () => { 358 + const r = parseRecurrenceRule('every day at 12pm'); 359 + expect(r).not.toBeNull(); 360 + expect(core(r).time.hour).toBe(12); 361 + expect(core(r).time.minute).toBe(0); 362 + }); 363 + 364 + it('parses 12am as midnight', () => { 365 + const r = parseRecurrenceRule('every day at 12am'); 366 + expect(r).not.toBeNull(); 367 + expect(core(r).time.hour).toBe(0); 368 + expect(core(r).time.minute).toBe(0); 369 + }); 370 + 371 + it('defaults to 9:00 when no time specified', () => { 372 + const r = parseRecurrenceRule('every day'); 373 + expect(r).not.toBeNull(); 374 + expect(core(r).time.hour).toBe(9); 375 + expect(core(r).time.minute).toBe(0); 376 + }); 377 + }); 378 + 379 + // --------------------------------------------------------------------------- 380 + // Interval multipliers 381 + // --------------------------------------------------------------------------- 382 + 383 + describe('parseRecurrenceRule — interval multipliers', () => { 384 + it('parses "every 2 weeks"', () => { 385 + const r = parseRecurrenceRule('every 2 weeks on Monday at 9am'); 386 + expect(r).not.toBeNull(); 387 + expect(core(r).interval).toBe(2); 388 + }); 389 + 390 + it('parses "every other day"', () => { 391 + const r = parseRecurrenceRule('every other day at 9am'); 392 + expect(r).not.toBeNull(); 393 + expect(core(r).interval).toBe(2); 394 + }); 395 + 396 + it('parses "every 3 months"', () => { 397 + const r = parseRecurrenceRule('every 3 months on the 1st at noon'); 398 + expect(r).not.toBeNull(); 399 + expect(core(r).interval).toBe(3); 400 + }); 401 + }); 402 + 403 + // --------------------------------------------------------------------------- 404 + // End conditions 405 + // --------------------------------------------------------------------------- 406 + 407 + describe('parseRecurrenceRule — end conditions', () => { 408 + it('extracts "for N times" count', () => { 409 + const r = parseRecurrenceRule('every Monday at 9am for 10 times'); 410 + expect(r).not.toBeNull(); 411 + expect(r!.count).toBe(10); 412 + expect(core(r).type).toBe('weekly'); 413 + }); 414 + 415 + it('extracts "until YYYY-MM-DD" end date', () => { 416 + const r = parseRecurrenceRule('every day at noon until 2026-12-31'); 417 + expect(r).not.toBeNull(); 418 + expect(r!.endDate).toBe('2026-12-31'); 419 + }); 420 + 421 + it('extracts "starting YYYY-MM-DD" start date', () => { 422 + const r = parseRecurrenceRule('every week on Friday at 5pm starting 2026-04-01'); 423 + expect(r).not.toBeNull(); 424 + expect(r!.startDate).toBe('2026-04-01'); 425 + }); 426 + 427 + it('extracts both start and end', () => { 428 + const r = parseRecurrenceRule('every Monday at 9am starting 2026-03-01 until 2026-06-30'); 429 + expect(r).not.toBeNull(); 430 + expect(r!.startDate).toBe('2026-03-01'); 431 + expect(r!.endDate).toBe('2026-06-30'); 432 + }); 433 + }); 434 + 435 + // --------------------------------------------------------------------------- 436 + // Null returns 437 + // --------------------------------------------------------------------------- 438 + 439 + describe('parseRecurrenceRule — null for unparseable input', () => { 440 + it('returns null for empty string', () => { 441 + expect(parseRecurrenceRule('')).toBeNull(); 442 + }); 443 + 444 + it('returns null for gibberish', () => { 445 + expect(parseRecurrenceRule('asdf qwerty blah')).toBeNull(); 446 + }); 447 + 448 + it('returns null for just a time with no recurrence keyword', () => { 449 + expect(parseRecurrenceRule('at 9am')).toBeNull(); 450 + }); 451 + });
+221
packages/recurrence/src/formatter.ts
··· 1 + // ABOUTME: Formats a RecurrenceRule as a plain-English string. 2 + 3 + import type { RecurrenceRule, RecurrenceRuleCore, TimeSpec } from './types.js'; 4 + 5 + // --------------------------------------------------------------------------- 6 + // Public API 7 + // --------------------------------------------------------------------------- 8 + 9 + /** 10 + * Format a RecurrenceRule as a human-readable English string. 11 + * 12 + * Examples: 13 + * "every weekday at 9am (ET)" 14 + * "last Friday of every month at 5pm (UTC)" 15 + * "every year on January 1st at midnight (America/New_York)" 16 + */ 17 + export function formatRecurrenceRule(rule: RecurrenceRule): string { 18 + const core = formatCore(rule.rule); 19 + 20 + const suffixes: string[] = []; 21 + if (rule.startDate) suffixes.push(`starting ${rule.startDate}`); 22 + if (rule.endDate) suffixes.push(`until ${rule.endDate}`); 23 + if (rule.count != null) suffixes.push(`for ${rule.count} ${rule.count === 1 ? 'occurrence' : 'occurrences'}`); 24 + 25 + return suffixes.length > 0 ? `${core}, ${suffixes.join(', ')}` : core; 26 + } 27 + 28 + // --------------------------------------------------------------------------- 29 + // Core rule formatting 30 + // --------------------------------------------------------------------------- 31 + 32 + function formatCore(rule: RecurrenceRuleCore): string { 33 + switch (rule.type) { 34 + case 'daily': 35 + return formatDaily(rule.interval ?? 1, rule.time); 36 + 37 + case 'weekly': 38 + return formatWeekly(rule.interval ?? 1, rule.daysOfWeek, rule.time); 39 + 40 + case 'monthly_on_day': 41 + return formatMonthlyOnDay(rule.interval ?? 1, rule.dayOfMonth, rule.time); 42 + 43 + case 'monthly_nth_weekday': 44 + return formatMonthlyNthWeekday(rule.interval ?? 1, rule.nth, rule.weekday, rule.time); 45 + 46 + case 'monthly_last_business_day': 47 + return formatMonthlyLastBusinessDay(rule.interval ?? 1, rule.time); 48 + 49 + case 'quarterly_last_weekday': 50 + return `last ${WEEKDAY_NAMES[rule.weekday]} of every quarter at ${formatTime(rule.time)}`; 51 + 52 + case 'yearly_on_month_day': 53 + return formatYearlyOnMonthDay(rule.interval ?? 1, rule.month, rule.dayOfMonth, rule.time); 54 + 55 + case 'yearly_nth_weekday': 56 + return formatYearlyNthWeekday(rule.interval ?? 1, rule.month, rule.nth, rule.weekday, rule.time); 57 + 58 + case 'once': 59 + return `once on ${formatIsoDatetime(rule.datetime)}`; 60 + } 61 + } 62 + 63 + // --------------------------------------------------------------------------- 64 + // Per-type formatters 65 + // --------------------------------------------------------------------------- 66 + 67 + function formatDaily(interval: number, time: TimeSpec): string { 68 + const t = formatTime(time); 69 + if (interval === 1) return `every day at ${t}`; 70 + if (interval === 2) return `every other day at ${t}`; 71 + return `every ${interval} days at ${t}`; 72 + } 73 + 74 + function formatWeekly(interval: number, daysOfWeek: number[], time: TimeSpec): string { 75 + const t = formatTime(time); 76 + 77 + // Special aliases 78 + if (arraysEqual(daysOfWeek.slice().sort((a, b) => a - b), [1, 2, 3, 4, 5])) { 79 + return interval === 1 80 + ? `every weekday at ${t}` 81 + : `every ${interval} weeks on weekdays at ${t}`; 82 + } 83 + if (arraysEqual(daysOfWeek.slice().sort((a, b) => a - b), [0, 6])) { 84 + return interval === 1 85 + ? `every weekend at ${t}` 86 + : `every ${interval} weeks on weekends at ${t}`; 87 + } 88 + 89 + const dayList = formatDayList(daysOfWeek); 90 + 91 + if (interval === 1) return `every ${dayList} at ${t}`; 92 + if (interval === 2) return `every other week on ${dayList} at ${t}`; 93 + return `every ${interval} weeks on ${dayList} at ${t}`; 94 + } 95 + 96 + function formatMonthlyOnDay(interval: number, dayOfMonth: number, time: TimeSpec): string { 97 + const t = formatTime(time); 98 + const dom = ordinal(dayOfMonth); 99 + if (interval === 1) return `every month on the ${dom} at ${t}`; 100 + if (interval === 2) return `every other month on the ${dom} at ${t}`; 101 + return `every ${interval} months on the ${dom} at ${t}`; 102 + } 103 + 104 + function formatMonthlyNthWeekday(interval: number, nth: number, weekday: number, time: TimeSpec): string { 105 + const t = formatTime(time); 106 + const day = WEEKDAY_NAMES[weekday] ?? `day ${weekday}`; 107 + const ord = nth === -1 ? 'last' : ordinal(nth); 108 + 109 + if (interval === 1) return `${ord} ${day} of every month at ${t}`; 110 + if (interval === 2) return `${ord} ${day} of every other month at ${t}`; 111 + return `${ord} ${day} of every ${interval} months at ${t}`; 112 + } 113 + 114 + function formatMonthlyLastBusinessDay(interval: number, time: TimeSpec): string { 115 + const t = formatTime(time); 116 + if (interval === 1) return `last business day of every month at ${t}`; 117 + if (interval === 2) return `last business day of every other month at ${t}`; 118 + return `last business day of every ${interval} months at ${t}`; 119 + } 120 + 121 + function formatYearlyOnMonthDay(interval: number, month: number, dayOfMonth: number, time: TimeSpec): string { 122 + const t = formatTime(time); 123 + const mon = MONTH_NAMES[month - 1] ?? `month ${month}`; 124 + const dom = ordinal(dayOfMonth); 125 + if (interval === 1) return `every year on ${mon} ${dom} at ${t}`; 126 + return `every ${interval} years on ${mon} ${dom} at ${t}`; 127 + } 128 + 129 + function formatYearlyNthWeekday(interval: number, month: number, nth: number, weekday: number, time: TimeSpec): string { 130 + const t = formatTime(time); 131 + const mon = MONTH_NAMES[month - 1] ?? `month ${month}`; 132 + const day = WEEKDAY_NAMES[weekday] ?? `day ${weekday}`; 133 + const ord = nth === -1 ? 'last' : ordinal(nth); 134 + if (interval === 1) return `${ord} ${day} of ${mon} every year at ${t}`; 135 + return `${ord} ${day} of ${mon} every ${interval} years at ${t}`; 136 + } 137 + 138 + // --------------------------------------------------------------------------- 139 + // Time formatting 140 + // --------------------------------------------------------------------------- 141 + 142 + function formatTime(spec: TimeSpec): string { 143 + const hour = spec.hour; 144 + const minute = spec.minute ?? 0; 145 + const tzSuffix = formatTz(spec); 146 + 147 + if (hour === 12 && minute === 0) return `noon${tzSuffix}`; 148 + if (hour === 0 && minute === 0) return `midnight${tzSuffix}`; 149 + 150 + const isPm = hour >= 12; 151 + const h = hour % 12 || 12; 152 + const m = minute > 0 ? `:${String(minute).padStart(2, '0')}` : ''; 153 + const ampm = isPm ? 'pm' : 'am'; 154 + return `${h}${m}${ampm}${tzSuffix}`; 155 + } 156 + 157 + function formatTz(spec: TimeSpec): string { 158 + if (spec.type === 'fixed_instant') { 159 + const offset = spec.utcOffsetMinutes; 160 + if (offset === 0) return ' (UTC)'; 161 + const sign = offset < 0 ? '-' : '+'; 162 + const abs = Math.abs(offset); 163 + const h = Math.floor(abs / 60); 164 + const m = abs % 60; 165 + return m > 0 166 + ? ` (UTC${sign}${h}:${String(m).padStart(2, '0')})` 167 + : ` (UTC${sign}${h})`; 168 + } 169 + 170 + // wall_time — try to show a friendly abbreviation, otherwise show IANA 171 + const iana = spec.timezone; 172 + const abbr = IANA_TO_ABBR[iana]; 173 + if (abbr) return ` (${abbr})`; 174 + if (iana === 'UTC') return ' (UTC)'; 175 + return ` (${iana})`; 176 + } 177 + 178 + // --------------------------------------------------------------------------- 179 + // Helpers 180 + // --------------------------------------------------------------------------- 181 + 182 + const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; 183 + const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 184 + 'July', 'August', 'September', 'October', 'November', 'December']; 185 + 186 + const IANA_TO_ABBR: Record<string, string> = { 187 + 'America/New_York': 'ET', 188 + 'America/Chicago': 'CT', 189 + 'America/Denver': 'MT', 190 + 'America/Los_Angeles': 'PT', 191 + 'Europe/London': 'London', 192 + 'Europe/Paris': 'Paris', 193 + 'Asia/Tokyo': 'JST', 194 + 'Australia/Sydney': 'Sydney', 195 + }; 196 + 197 + function ordinal(n: number): string { 198 + const s = ['th', 'st', 'nd', 'rd']; 199 + const v = n % 100; 200 + return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]!); 201 + } 202 + 203 + function formatDayList(days: number[]): string { 204 + const sorted = days.slice().sort((a, b) => a - b); 205 + const names = sorted.map(d => WEEKDAY_NAMES[d] ?? `day ${d}`); 206 + if (names.length === 1) return names[0]!; 207 + if (names.length === 2) return `${names[0]} and ${names[1]}`; 208 + return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; 209 + } 210 + 211 + function arraysEqual(a: number[], b: number[]): boolean { 212 + return a.length === b.length && a.every((v, i) => v === b[i]); 213 + } 214 + 215 + function formatIsoDatetime(iso: string): string { 216 + const d = new Date(iso); 217 + return d.toLocaleString('en-US', { 218 + year: 'numeric', month: 'long', day: 'numeric', 219 + hour: 'numeric', minute: '2-digit', timeZone: 'UTC', timeZoneName: 'short', 220 + }); 221 + }
+2
packages/recurrence/src/index.ts
··· 1 1 // ABOUTME: Public API for @newpublic/recurrence 2 2 3 3 export { computeNextOccurrence, getOccurrenceRecord } from './engine.js'; 4 + export { parseRecurrenceRule } from './parser.js'; 5 + export { formatRecurrenceRule } from './formatter.js'; 4 6 export type { 5 7 RecurrenceRule, 6 8 RecurrenceRuleCore,
+450
packages/recurrence/src/parser.ts
··· 1 + // ABOUTME: Natural language → RecurrenceRule parser. Zero dependencies, deterministic regex/grammar approach. 2 + 3 + import type { 4 + RecurrenceRule, 5 + RecurrenceRuleCore, 6 + WallTime, 7 + } from './types.js'; 8 + 9 + // --------------------------------------------------------------------------- 10 + // Public API 11 + // --------------------------------------------------------------------------- 12 + 13 + /** 14 + * Parse a plain-English recurrence description into a RecurrenceRule. 15 + * Returns null if the input doesn't match any known pattern. 16 + * 17 + * @param input - Natural language string, e.g. "every Monday at 9am ET" 18 + * @param defaultTimezone - IANA timezone to use when none detected; defaults to 'UTC' 19 + */ 20 + export function parseRecurrenceRule( 21 + input: string, 22 + defaultTimezone = 'UTC', 23 + ): RecurrenceRule | null { 24 + // Capture IANA timezone from original input (before lowercasing) so we preserve casing 25 + const originalIanaMatch = input.match(IANA_RE); 26 + const originalIana = originalIanaMatch ? originalIanaMatch[0] : null; 27 + 28 + const text = normalize(input); 29 + if (!text) return null; 30 + 31 + // Extract end conditions first, removing them from text 32 + const { cleaned, startDate, endDate, count } = extractEndConditions(text); 33 + 34 + // Extract time info (pass original IANA if found so it's used verbatim) 35 + const timeResult = extractTime(cleaned, defaultTimezone, originalIana); 36 + const timeSpec = timeResult.time; 37 + const withoutTime = timeResult.remaining; 38 + 39 + // Detect rule type (ordered most-specific → least-specific) 40 + const core = detectRule(withoutTime, timeSpec); 41 + if (!core) return null; 42 + 43 + const rule: RecurrenceRule = { rule: core }; 44 + if (startDate) rule.startDate = startDate; 45 + if (endDate) rule.endDate = endDate; 46 + if (count != null) rule.count = count; 47 + 48 + return rule; 49 + } 50 + 51 + // --------------------------------------------------------------------------- 52 + // Normalization 53 + // --------------------------------------------------------------------------- 54 + 55 + function normalize(input: string): string { 56 + return input 57 + .toLowerCase() 58 + .replace(/\s+/g, ' ') 59 + .replace(/[.,;!?]+$/, '') 60 + .trim(); 61 + } 62 + 63 + // --------------------------------------------------------------------------- 64 + // End condition extraction 65 + // --------------------------------------------------------------------------- 66 + 67 + interface EndConditions { 68 + cleaned: string; 69 + startDate?: string; 70 + endDate?: string; 71 + count?: number; 72 + } 73 + 74 + function extractEndConditions(text: string): EndConditions { 75 + let cleaned = text; 76 + let startDate: string | undefined; 77 + let endDate: string | undefined; 78 + let count: number | undefined; 79 + 80 + // "starting YYYY-MM-DD" or "starting on YYYY-MM-DD" 81 + cleaned = cleaned.replace( 82 + /\bstarting (?:on )?(\d{4}-\d{2}-\d{2})\b/, 83 + (_, d) => { startDate = d; return ''; }, 84 + ); 85 + 86 + // "until YYYY-MM-DD" or "through YYYY-MM-DD" 87 + cleaned = cleaned.replace( 88 + /\b(?:until|through|thru|ending|ends?) (?:on )?(\d{4}-\d{2}-\d{2})\b/, 89 + (_, d) => { endDate = d; return ''; }, 90 + ); 91 + 92 + // "for N times" / "for N occurrences" / "N times" 93 + cleaned = cleaned.replace( 94 + /\bfor (\d+) (?:times?|occurrences?|repetitions?)\b/, 95 + (_, n) => { count = parseInt(n, 10); return ''; }, 96 + ); 97 + if (count == null) { 98 + cleaned = cleaned.replace( 99 + /\b(\d+) (?:times?|occurrences?)\b/, 100 + (_, n) => { count = parseInt(n, 10); return ''; }, 101 + ); 102 + } 103 + 104 + cleaned = cleaned.replace(/\s+/g, ' ').trim(); 105 + return { cleaned, startDate, endDate, count }; 106 + } 107 + 108 + // --------------------------------------------------------------------------- 109 + // Time extraction 110 + // --------------------------------------------------------------------------- 111 + 112 + interface TimeResult { 113 + time: WallTime; 114 + remaining: string; 115 + } 116 + 117 + function extractTime(text: string, defaultTimezone: string, originalIana?: string | null): TimeResult { 118 + let remaining = text; 119 + let hour = 9; 120 + let minute = 0; 121 + // Use the original-case IANA string if we found one, else detect from lowercased text 122 + let timezone = originalIana ?? parseTimezone(text, defaultTimezone); 123 + 124 + // Remove timezone abbreviations/IANA strings from text 125 + remaining = removeTimezone(remaining, timezone, defaultTimezone); 126 + 127 + // "at noon" / "at midnight" 128 + const noonMatch = remaining.match(/\bat noon\b/); 129 + const midnightMatch = remaining.match(/\bat midnight\b/); 130 + if (noonMatch) { 131 + hour = 12; minute = 0; 132 + remaining = remaining.replace(/\bat noon\b/, '').trim(); 133 + } else if (midnightMatch) { 134 + hour = 0; minute = 0; 135 + remaining = remaining.replace(/\bat midnight\b/, '').trim(); 136 + } else { 137 + // "at H:MM am/pm", "at H am/pm", "at HH:MM" (24h) 138 + const timeRe = /\bat (\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b/; 139 + const m = remaining.match(timeRe); 140 + if (m) { 141 + let h = parseInt(m[1], 10); 142 + const min = m[2] ? parseInt(m[2], 10) : 0; 143 + const ampm = m[3]; 144 + if (ampm === 'pm' && h < 12) h += 12; 145 + if (ampm === 'am' && h === 12) h = 0; 146 + hour = h; 147 + minute = min; 148 + remaining = remaining.replace(timeRe, '').trim(); 149 + } 150 + } 151 + 152 + remaining = remaining.replace(/\s+/g, ' ').trim(); 153 + 154 + return { 155 + time: { type: 'wall_time', hour, minute, timezone }, 156 + remaining, 157 + }; 158 + } 159 + 160 + // --------------------------------------------------------------------------- 161 + // Timezone parsing 162 + // --------------------------------------------------------------------------- 163 + 164 + const TZ_MAP: Record<string, string> = { 165 + 'est': 'America/New_York', 166 + 'edt': 'America/New_York', 167 + 'et': 'America/New_York', 168 + 'eastern': 'America/New_York', 169 + 'eastern time': 'America/New_York', 170 + 'cst': 'America/Chicago', 171 + 'cdt': 'America/Chicago', 172 + 'ct': 'America/Chicago', 173 + 'central': 'America/Chicago', 174 + 'central time': 'America/Chicago', 175 + 'mst': 'America/Denver', 176 + 'mdt': 'America/Denver', 177 + 'mt': 'America/Denver', 178 + 'mountain': 'America/Denver', 179 + 'mountain time': 'America/Denver', 180 + 'pst': 'America/Los_Angeles', 181 + 'pdt': 'America/Los_Angeles', 182 + 'pt': 'America/Los_Angeles', 183 + 'pacific': 'America/Los_Angeles', 184 + 'pacific time': 'America/Los_Angeles', 185 + 'gmt': 'UTC', 186 + 'utc': 'UTC', 187 + 'bst': 'Europe/London', 188 + 'london': 'Europe/London', 189 + 'cet': 'Europe/Paris', 190 + 'cest': 'Europe/Paris', 191 + 'paris': 'Europe/Paris', 192 + 'jst': 'Asia/Tokyo', 193 + 'tokyo': 'Asia/Tokyo', 194 + 'aest': 'Australia/Sydney', 195 + 'aedt': 'Australia/Sydney', 196 + 'sydney': 'Australia/Sydney', 197 + }; 198 + 199 + // Known IANA timezone pattern 200 + const IANA_RE = /\b(america|europe|asia|australia|africa|pacific|atlantic|indian|arctic|antarctica)\/[\w_]+\b/i; 201 + 202 + function parseTimezone(text: string, defaultTz: string): string { 203 + // Check for IANA timezone first 204 + const ianaMatch = text.match(IANA_RE); 205 + if (ianaMatch) return ianaMatch[0]; 206 + 207 + // Check multi-word timezone phrases first (longer matches take priority) 208 + const multiWord = ['eastern time', 'central time', 'mountain time', 'pacific time']; 209 + for (const phrase of multiWord) { 210 + if (text.includes(phrase)) { 211 + return TZ_MAP[phrase]!; 212 + } 213 + } 214 + 215 + // Check single-word abbreviations 216 + // Use word boundary matching 217 + for (const [abbr, iana] of Object.entries(TZ_MAP)) { 218 + if (abbr.includes(' ')) continue; // Already handled above 219 + const re = new RegExp(`\\b${abbr}\\b`, 'i'); 220 + if (re.test(text)) return iana; 221 + } 222 + 223 + return defaultTz; 224 + } 225 + 226 + function removeTimezone(text: string, detectedTz: string, defaultTz: string): string { 227 + if (detectedTz === defaultTz) return text; // Nothing detected, nothing to remove 228 + 229 + // Remove IANA timezone 230 + let result = text.replace(IANA_RE, ''); 231 + 232 + // Remove timezone phrases (longest first to avoid partial matches) 233 + const phrases = [ 234 + 'eastern time', 'central time', 'mountain time', 'pacific time', 235 + 'eastern', 'central', 'mountain', 'pacific', 236 + 'london', 'paris', 'tokyo', 'sydney', 237 + 'est', 'edt', 'et', 238 + 'cst', 'cdt', 'ct', 239 + 'mst', 'mdt', 'mt', 240 + 'pst', 'pdt', 'pt', 241 + 'gmt', 'utc', 'bst', 'cet', 'cest', 'jst', 'aest', 'aedt', 242 + ]; 243 + 244 + for (const phrase of phrases) { 245 + const re = new RegExp(`\\b${phrase}\\b`, 'gi'); 246 + result = result.replace(re, ''); 247 + } 248 + 249 + return result.replace(/\s+/g, ' ').trim(); 250 + } 251 + 252 + // --------------------------------------------------------------------------- 253 + // Interval parsing 254 + // --------------------------------------------------------------------------- 255 + 256 + function parseInterval(text: string): number { 257 + // "every other" → 2 258 + if (/\bevery other\b/.test(text)) return 2; 259 + 260 + // "every N" / "every Nth" 261 + const m = text.match(/\bevery (\d+)(?:st|nd|rd|th)?\b/); 262 + if (m) return parseInt(m[1], 10); 263 + 264 + // "each N" 265 + const m2 = text.match(/\beach (\d+)(?:st|nd|rd|th)?\b/); 266 + if (m2) return parseInt(m2[1], 10); 267 + 268 + return 1; 269 + } 270 + 271 + // --------------------------------------------------------------------------- 272 + // Weekday parsing 273 + // --------------------------------------------------------------------------- 274 + 275 + const WEEKDAY_MAP: Record<string, number> = { 276 + 'sunday': 0, 'sun': 0, 277 + 'monday': 1, 'mon': 1, 278 + 'tuesday': 2, 'tue': 2, 'tues': 2, 279 + 'wednesday': 3, 'wed': 3, 280 + 'thursday': 4, 'thu': 4, 'thur': 4, 'thurs': 4, 281 + 'friday': 5, 'fri': 5, 282 + 'saturday': 6, 'sat': 6, 283 + }; 284 + 285 + function parseWeekdays(text: string): number[] { 286 + if (/\bweekdays?\b/.test(text) || /\bwork\s*days?\b/.test(text)) return [1, 2, 3, 4, 5]; 287 + if (/\bweekends?\b/.test(text)) return [0, 6]; 288 + 289 + const found: number[] = []; 290 + 291 + // Match full names and abbreviations 292 + const dayPattern = /\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|mon|tue|tues|wed|thu|thur|thurs|fri|sat|sun)\b/g; 293 + let m: RegExpExecArray | null; 294 + while ((m = dayPattern.exec(text)) !== null) { 295 + const day = WEEKDAY_MAP[m[1]]; 296 + if (day != null && !found.includes(day)) { 297 + found.push(day); 298 + } 299 + } 300 + 301 + return found.sort((a, b) => a - b); 302 + } 303 + 304 + // --------------------------------------------------------------------------- 305 + // Ordinal (nth) parsing 306 + // --------------------------------------------------------------------------- 307 + 308 + function parseNth(text: string): number | null { 309 + if (/\blast\b/.test(text)) return -1; 310 + if (/\bfirst\b|\b1st\b/.test(text)) return 1; 311 + if (/\bsecond\b|\b2nd\b/.test(text)) return 2; 312 + if (/\bthird\b|\b3rd\b/.test(text)) return 3; 313 + if (/\bfourth\b|\b4th\b/.test(text)) return 4; 314 + return null; 315 + } 316 + 317 + // --------------------------------------------------------------------------- 318 + // Month name parsing 319 + // --------------------------------------------------------------------------- 320 + 321 + const MONTH_MAP: Record<string, number> = { 322 + 'january': 1, 'jan': 1, 323 + 'february': 2, 'feb': 2, 324 + 'march': 3, 'mar': 3, 325 + 'april': 4, 'apr': 4, 326 + 'may': 5, 327 + 'june': 6, 'jun': 6, 328 + 'july': 7, 'jul': 7, 329 + 'august': 8, 'aug': 8, 330 + 'september': 9, 'sep': 9, 'sept': 9, 331 + 'october': 10, 'oct': 10, 332 + 'november': 11, 'nov': 11, 333 + 'december': 12, 'dec': 12, 334 + }; 335 + 336 + function parseMonthName(text: string): number | null { 337 + const m = text.match(/\b(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec)\b/); 338 + if (m) return MONTH_MAP[m[1]] ?? null; 339 + return null; 340 + } 341 + 342 + // --------------------------------------------------------------------------- 343 + // Rule detection (ordered most-specific → least-specific) 344 + // --------------------------------------------------------------------------- 345 + 346 + function detectRule(text: string, time: WallTime): RecurrenceRuleCore | null { 347 + // quarterly_last_weekday — "last [weekday] of each/every quarter" 348 + if (/\bquarter(?:ly)?\b/.test(text)) { 349 + if (/\blast\b/.test(text)) { 350 + const days = parseWeekdays(text); 351 + const weekday = days.length > 0 ? days[0] : 5; // default Friday 352 + return { type: 'quarterly_last_weekday', weekday, time }; 353 + } 354 + } 355 + 356 + // monthly_last_business_day — "last business day" / "last weekday of the month" 357 + if ( 358 + /\blast\s+business\s+day\b/.test(text) || 359 + /\blast\s+weekday\s+of\s+(?:the\s+)?month\b/.test(text) 360 + ) { 361 + const interval = parseInterval(text); 362 + return { type: 'monthly_last_business_day', interval, time }; 363 + } 364 + 365 + // yearly_nth_weekday — "[nth] [weekday] of [month] every year" / "annually" 366 + if (/\b(?:year(?:ly)?|annually|each year|every year)\b/.test(text)) { 367 + const month = parseMonthName(text); 368 + const nth = parseNth(text); 369 + const days = parseWeekdays(text); 370 + if (month != null && nth != null && days.length > 0) { 371 + return { type: 'yearly_nth_weekday', month, nth, weekday: days[0], time }; 372 + } 373 + if (month != null) { 374 + // yearly_on_month_day 375 + const domMatch = text.match(/\b(\d{1,2})(?:st|nd|rd|th)?\b/); 376 + const dayOfMonth = domMatch ? parseInt(domMatch[1], 10) : 1; 377 + return { type: 'yearly_on_month_day', month, dayOfMonth, time }; 378 + } 379 + // No month specified — can't determine a yearly rule 380 + return null; 381 + } 382 + 383 + // monthly_nth_weekday — "[nth/last] [weekday] of (each|every) month" 384 + if (/\b(?:month(?:ly)?|each month|every month)\b/.test(text)) { 385 + const nth = parseNth(text); 386 + const days = parseWeekdays(text); 387 + if (nth != null && days.length > 0) { 388 + const interval = parseInterval(text); 389 + return { type: 'monthly_nth_weekday', nth, weekday: days[0], interval, time }; 390 + } 391 + 392 + // monthly_last_business_day (also catches "last business day of each month") 393 + if (/\blast\s+business\s+day\b/.test(text)) { 394 + const interval = parseInterval(text); 395 + return { type: 'monthly_last_business_day', interval, time }; 396 + } 397 + 398 + // monthly_on_day — "every month on the Nth" / "Nth of each month" 399 + const domMatch = text.match(/\b(\d{1,2})(?:st|nd|rd|th)?\b/); 400 + if (domMatch) { 401 + const dayOfMonth = parseInt(domMatch[1], 10); 402 + const interval = parseInterval(text); 403 + return { type: 'monthly_on_day', dayOfMonth, interval, time }; 404 + } 405 + 406 + // Monthly with no further spec — default to 1st of month 407 + return { type: 'monthly_on_day', dayOfMonth: 1, interval: 1, time }; 408 + } 409 + 410 + // monthly_on_day — "Nth of each month" patterns without explicit "monthly" 411 + const nthOfMonth = text.match(/\b(\d{1,2})(?:st|nd|rd|th)?\s+of\s+(?:each|every|the)\s+month\b/); 412 + if (nthOfMonth) { 413 + const dayOfMonth = parseInt(nthOfMonth[1], 10); 414 + return { type: 'monthly_on_day', dayOfMonth, interval: 1, time }; 415 + } 416 + 417 + // weekly — "every [weekday list]" / "weekdays" / "weekends" / "every N weeks on ..." 418 + if ( 419 + /\bweek(?:ly)?\b/.test(text) || 420 + /\bweekdays?\b/.test(text) || 421 + /\bweekends?\b/.test(text) || 422 + /\bwork\s*days?\b/.test(text) 423 + ) { 424 + const days = parseWeekdays(text); 425 + const daysOfWeek = days.length > 0 ? days : [1]; // default Monday 426 + const interval = parseInterval(text); 427 + return { type: 'weekly', daysOfWeek, interval, time }; 428 + } 429 + 430 + // Check for weekday names without explicit "weekly" keyword 431 + const namedDays = parseWeekdays(text); 432 + if (namedDays.length > 0) { 433 + const interval = parseInterval(text); 434 + return { type: 'weekly', daysOfWeek: namedDays, interval, time }; 435 + } 436 + 437 + // daily — "every day" / "daily" / "every N days" 438 + if (/\b(?:daily|every day|each day|every \d+ days?)\b/.test(text) || /\bdays?\b/.test(text)) { 439 + const interval = parseInterval(text); 440 + return { type: 'daily', interval, time }; 441 + } 442 + 443 + // If text contains "every" or "each" but nothing else matched, try daily 444 + if (/\b(?:every|each)\b/.test(text)) { 445 + const interval = parseInterval(text); 446 + return { type: 'daily', interval, time }; 447 + } 448 + 449 + return null; 450 + }
+1
src/__tests__/database.test.ts
··· 28 28 maxDraftsPerUser: null, 29 29 allowedCollections: '*', 30 30 oauthScope: 'atproto repo:*?action=create blob:*/*', 31 + disableRecurring: false, 31 32 ...overrides, 32 33 }); 33 34
+1
src/__tests__/oauth.test.ts
··· 38 38 maxDraftsPerUser: null, 39 39 allowedCollections: '*', 40 40 oauthScope: 'atproto repo:*?action=create blob:*/*', 41 + disableRecurring: false, 41 42 }); 42 43 43 44 describe('createOAuthClient', () => {
+1
src/__tests__/routes/oauth.test.ts
··· 23 23 maxDraftsPerUser: null, 24 24 allowedCollections: '*', 25 25 oauthScope: 'atproto repo:*?action=create blob:*/*', 26 + disableRecurring: false, 26 27 ...overrides, 27 28 }); 28 29
+1
src/__tests__/scheduler.test.ts
··· 52 52 maxDraftsPerUser: null, 53 53 allowedCollections: '*', 54 54 oauthScope: 'atproto repo:*?action=create blob:*/*', 55 + disableRecurring: false, 55 56 }); 56 57 57 58 /** Flush all pending microtasks and I/O callbacks */
+1
src/__tests__/server.test.ts
··· 92 92 maxDraftsPerUser: null, 93 93 allowedCollections: '*', 94 94 oauthScope: 'atproto repo:*?action=create blob:*/*', 95 + disableRecurring: false, 95 96 }; 96 97 97 98 const AUTH_HEADER = 'Bearer test-token';
+1
src/__tests__/storage.test.ts
··· 53 53 maxDraftsPerUser: null, 54 54 allowedCollections: '*', 55 55 oauthScope: 'atproto repo:*?action=create blob:*/*', 56 + disableRecurring: false, 56 57 }); 57 58 58 59 describe('storage', () => {
+3
src/config.ts
··· 19 19 postPublishWebhookUrl?: string; 20 20 /** Maximum number of active drafts per user. null = unlimited. */ 21 21 maxDraftsPerUser: number | null; 22 + /** When true, createSchedule is rejected with a 403. For demo deployments. */ 23 + disableRecurring: boolean; 22 24 /** 23 25 * Comma-separated ATProto collection NSIDs to allow. Defaults to "*" (all collections). 24 26 * Used to build the OAuth scope: `repo:<collection>?action=create`. ··· 46 48 encryptionKey: process.env.ENCRYPTION_KEY || '', 47 49 postPublishWebhookUrl: process.env.POST_PUBLISH_WEBHOOK_URL, 48 50 maxDraftsPerUser, 51 + disableRecurring: ['true', '1', 'yes'].includes((process.env.DISABLE_RECURRING || '').toLowerCase()), 49 52 allowedCollections: process.env.ALLOWED_COLLECTIONS || '*', 50 53 oauthScope: `atproto repo:${process.env.ALLOWED_COLLECTIONS || '*'}?action=create blob:*/*`, 51 54 };
+5
src/server.ts
··· 240 240 res.json({ 241 241 authorized: !!auth, 242 242 authType: auth?.auth_type ?? null, 243 + disableRecurring: config.disableRecurring, 243 244 }); 244 245 } catch (err) { 245 246 logger.warn('oauth/status auth failed', { error: err instanceof Error ? err.message : /* istanbul ignore next */ String(err) }); ··· 879 880 // ------------------------------------------------------------------------- 880 881 881 882 server.method('town.roundabout.scheduledPosts.createSchedule', async (ctx: xrpc.HandlerContext) => { 883 + if (config.disableRecurring) { 884 + throw new xrpc.AuthRequiredError('Recurring schedules are disabled on this server'); 885 + } 886 + 882 887 const user = await requireAuth(ctx.req.headers.authorization, ctx.req.headers.dpop as string | undefined); 883 888 884 889 const body = ctx.input?.body as {