@contalert/duedates 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ContAlert Sistemas Ltda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,150 @@
1
- # Temporary Holding Version
1
+ # duedates
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Recurring deadlines that respect weekends, holidays and month boundaries.**
4
+
5
+ `duedates` answers the questions every billing, payroll, compliance and reminder system eventually has to answer:
6
+
7
+ - *"Invoices are due on the 20th — but what if the 20th is a Sunday?"*
8
+ - *"When is the 5th business day of next month?"*
9
+ - *"Which dates should we send reminders on, 7, 3 and 1 business days before?"*
10
+
11
+ [![CI](https://github.com/contalert/duedates/actions/workflows/ci.yml/badge.svg)](https://github.com/contalert/duedates/actions/workflows/ci.yml)
12
+ ![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
13
+ ![license MIT](https://img.shields.io/badge/license-MIT-blue)
14
+
15
+ ## Why
16
+
17
+ - **Time-zone safe.** Dates are plain `YYYY-MM-DD` strings and all math is integer day arithmetic. No `Date` object can shift your deadline by a day because of a time zone or daylight saving time.
18
+ - **Real-world conventions.** Following, preceding, *modified following* and *modified preceding* — the same business-day conventions used in finance.
19
+ - **Any country.** Choose your weekend (e.g. Friday–Saturday) and bring your own holidays: a list of dates or a function.
20
+ - **Zero dependencies**, fully typed, ESM, tiny.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ npm install @contalert/duedates
26
+ ```
27
+
28
+ Requires Node.js 20 or later (or any modern browser or runtime).
29
+
30
+ ## Quick start
31
+
32
+ ```ts
33
+ import { createCalendar, occurrences, reminderDates } from '@contalert/duedates';
34
+
35
+ const calendar = createCalendar({
36
+ holidays: ['2026-12-25', '2027-01-01'],
37
+ });
38
+
39
+ // "Due on the 20th of every month; if that's not a business day, the next one."
40
+ const deadlines = occurrences(
41
+ { type: 'dayOfMonth', day: 20, adjust: 'following' },
42
+ { from: '2026-11-03', count: 3, calendar },
43
+ );
44
+ // [
45
+ // { nominal: '2026-11-20', due: '2026-11-20' },
46
+ // { nominal: '2026-12-20', due: '2026-12-21' }, ← Sunday moved to Monday
47
+ // { nominal: '2027-01-20', due: '2027-01-20' },
48
+ // ]
49
+
50
+ reminderDates('2026-12-21', [7, 3, 1], { calendar });
51
+ // ['2026-12-10', '2026-12-16', '2026-12-18']
52
+ ```
53
+
54
+ ## Rules
55
+
56
+ Every rule produces a **nominal** date (what the rule says) and a **due** date (after adjusting for non-business days).
57
+
58
+ | Rule | Example | Meaning |
59
+ |---|---|---|
60
+ | `dayOfMonth` | `{ type: 'dayOfMonth', day: 20 }` | The 20th of every month. Days past the end of a month use its last day (31 → 28/29 Feb). |
61
+ | | `{ type: 'dayOfMonth', day: 'last' }` | The last day of every month. |
62
+ | `weekdayOfMonth` | `{ type: 'weekdayOfMonth', weekday: 2, nth: 2 }` | The second Tuesday of every month. |
63
+ | | `{ type: 'weekdayOfMonth', weekday: 5, nth: -1 }` | The last Friday of every month. |
64
+ | `businessDayOfMonth` | `{ type: 'businessDayOfMonth', n: 5 }` | The 5th business day of every month. |
65
+ | | `{ type: 'businessDayOfMonth', n: -1 }` | The last business day of every month. |
66
+ | `weekly` | `{ type: 'weekly', weekday: 1 }` | Every Monday. |
67
+
68
+ Weekdays follow ISO 8601: `1` = Monday … `7` = Sunday.
69
+
70
+ Monthly rules accept `months` to run only in some months:
71
+
72
+ ```ts
73
+ // Quarterly: last day of March, June, September and December
74
+ { type: 'dayOfMonth', day: 'last', months: [3, 6, 9, 12] }
75
+
76
+ // Yearly: April 15th
77
+ { type: 'dayOfMonth', day: 15, months: [4] }
78
+ ```
79
+
80
+ ### Adjustments
81
+
82
+ All rules except `businessDayOfMonth` (which is always a business day) accept `adjust`:
83
+
84
+ | `adjust` | When the date is not a business day… |
85
+ |---|---|
86
+ | `'none'` *(default)* | keep it. |
87
+ | `'following'` | move to the next business day. |
88
+ | `'preceding'` | move to the previous business day. |
89
+ | `'modifiedFollowing'` | move forward, unless that crosses into the next month — then move back. |
90
+ | `'modifiedPreceding'` | move back, unless that crosses into the previous month — then move forward. |
91
+
92
+ ## API
93
+
94
+ ### `occurrences(rule, { from, to?, count?, calendar? })`
95
+
96
+ Returns `{ nominal, due }[]` for every occurrence whose due date is on or after `from`, stopping at `to` (inclusive), after `count` results, or both. At least one of `to` and `count` is required.
97
+
98
+ ### `nextDue(rule, { from, calendar? })`
99
+
100
+ The first occurrence due on or after `from`.
101
+
102
+ ### `createCalendar({ weekend?, holidays? })`
103
+
104
+ - `weekend` — weekdays that are never business days. Default `[6, 7]` (Saturday, Sunday).
105
+ - `holidays` — an iterable of `YYYY-MM-DD` strings, or a function `(date) => boolean`.
106
+
107
+ The calendar has:
108
+
109
+ | Method | |
110
+ |---|---|
111
+ | `isBusinessDay(date)` / `isWeekend(date)` / `isHoliday(date)` | Checks a date. |
112
+ | `adjust(date, adjustment)` | Applies one of the adjustments above. |
113
+ | `addBusinessDays(date, n)` | Moves `n` business days forward (or back, if negative). |
114
+ | `businessDaysBetween(start, end)` | Business days after `start` up to and including `end`. |
115
+
116
+ ### `reminderDates(due, offsets, { unit?, calendar? })`
117
+
118
+ The dates to send reminders on, `offsets` days before `due`, sorted and de-duplicated. `unit` is `'businessDays'` (default) or `'calendarDays'`.
119
+
120
+ ### Date helpers
121
+
122
+ `addDays(date, n)`, `weekdayOf(date)`, `daysInMonth(year, month)`, `isLeapYear(year)`, `isValidDate(value)` and `today(timeZone?)` — for example `today('America/New_York')`.
123
+
124
+ Invalid input (a date that doesn't exist, an unknown rule, …) throws a `RangeError` with a clear message.
125
+
126
+ ## Holidays for your country
127
+
128
+ `duedates` doesn't ship holiday data — holiday rules change, and you know which ones apply to you. Pass a list, or plug in any holiday library. For example, with [`date-holidays`](https://www.npmjs.com/package/date-holidays):
129
+
130
+ ```ts
131
+ import Holidays from 'date-holidays';
132
+ import { createCalendar } from '@contalert/duedates';
133
+
134
+ const us = new Holidays('US');
135
+
136
+ const calendar = createCalendar({
137
+ holidays: (date) => {
138
+ const found = us.isHoliday(new Date(`${date}T12:00:00`));
139
+ return Array.isArray(found) && found.some((holiday) => holiday.type === 'public');
140
+ },
141
+ });
142
+ ```
143
+
144
+ ## Contributing
145
+
146
+ Issues and pull requests are welcome. Run `npm test` and `npm run typecheck` before opening a pull request. Please report security issues privately — see [SECURITY.md](SECURITY.md).
147
+
148
+ ## License
149
+
150
+ [MIT](LICENSE) © ContAlert Sistemas Ltda
@@ -0,0 +1,31 @@
1
+ import { type DateString, type Weekday } from './date.js';
2
+ /**
3
+ * How a date that falls on a non-business day is moved.
4
+ *
5
+ * - `none` — keep the date as is.
6
+ * - `following` — move forward to the next business day.
7
+ * - `preceding` — move back to the previous business day.
8
+ * - `modifiedFollowing` — move forward, unless that changes the month; then move back.
9
+ * - `modifiedPreceding` — move back, unless that changes the month; then move forward.
10
+ */
11
+ export type Adjustment = 'none' | 'following' | 'preceding' | 'modifiedFollowing' | 'modifiedPreceding';
12
+ export declare const ADJUSTMENTS: readonly Adjustment[];
13
+ export interface CalendarOptions {
14
+ /** Weekdays that are never business days (1 = Monday … 7 = Sunday). Defaults to `[6, 7]`. */
15
+ weekend?: readonly Weekday[];
16
+ /** Holidays, as a list of dates or a function that returns `true` for a holiday. */
17
+ holidays?: Iterable<DateString> | ((date: DateString) => boolean);
18
+ }
19
+ export interface Calendar {
20
+ isWeekend(date: DateString): boolean;
21
+ isHoliday(date: DateString): boolean;
22
+ /** Neither a weekend day nor a holiday. */
23
+ isBusinessDay(date: DateString): boolean;
24
+ /** Moves a date that is not a business day, following the given convention. */
25
+ adjust(date: DateString, adjustment: Adjustment): DateString;
26
+ /** Adds (or, with a negative `days`, subtracts) business days. `0` returns the date unchanged. */
27
+ addBusinessDays(date: DateString, days: number): DateString;
28
+ /** Business days after `start` up to and including `end`; negative when `end` is before `start`. */
29
+ businessDaysBetween(start: DateString, end: DateString): number;
30
+ }
31
+ export declare function createCalendar(options?: CalendarOptions): Calendar;
@@ -0,0 +1,98 @@
1
+ import { addDays, parseDate, weekdayOf } from './date.js';
2
+ export const ADJUSTMENTS = [
3
+ 'none',
4
+ 'following',
5
+ 'preceding',
6
+ 'modifiedFollowing',
7
+ 'modifiedPreceding',
8
+ ];
9
+ /** A calendar with no business day within this many days is treated as broken. */
10
+ const MAX_SEARCH_DAYS = 3660;
11
+ export function createCalendar(options = {}) {
12
+ const weekend = new Set(options.weekend ?? [6, 7]);
13
+ for (const day of weekend) {
14
+ if (!Number.isInteger(day) || day < 1 || day > 7) {
15
+ throw new RangeError(`Invalid weekday ${day}: expected 1 (Monday) to 7 (Sunday)`);
16
+ }
17
+ }
18
+ if (weekend.size === 7)
19
+ throw new RangeError('A calendar needs at least one weekday that is not a weekend day');
20
+ const { holidays } = options;
21
+ let holidayCheck;
22
+ if (typeof holidays === 'function') {
23
+ holidayCheck = holidays;
24
+ }
25
+ else {
26
+ const dates = new Set();
27
+ for (const date of holidays ?? []) {
28
+ parseDate(date);
29
+ dates.add(date);
30
+ }
31
+ holidayCheck = (date) => dates.has(date);
32
+ }
33
+ const isWeekend = (date) => weekend.has(weekdayOf(date));
34
+ const isHoliday = (date) => {
35
+ parseDate(date);
36
+ return holidayCheck(date);
37
+ };
38
+ const isBusinessDay = (date) => !isWeekend(date) && !isHoliday(date);
39
+ /** The date itself if it is a business day, otherwise the nearest one in `direction`. */
40
+ function seek(date, direction) {
41
+ let current = date;
42
+ for (let i = 0; i <= MAX_SEARCH_DAYS; i++) {
43
+ if (isBusinessDay(current))
44
+ return current;
45
+ current = addDays(current, direction);
46
+ }
47
+ throw new RangeError(`No business day found within ${MAX_SEARCH_DAYS} days of ${date}`);
48
+ }
49
+ const sameMonth = (a, b) => a.slice(0, 7) === b.slice(0, 7);
50
+ return {
51
+ isWeekend,
52
+ isHoliday,
53
+ isBusinessDay,
54
+ adjust(date, adjustment) {
55
+ switch (adjustment) {
56
+ case 'none':
57
+ parseDate(date);
58
+ return date;
59
+ case 'following':
60
+ return seek(date, 1);
61
+ case 'preceding':
62
+ return seek(date, -1);
63
+ case 'modifiedFollowing': {
64
+ const next = seek(date, 1);
65
+ return sameMonth(next, date) ? next : seek(date, -1);
66
+ }
67
+ case 'modifiedPreceding': {
68
+ const previous = seek(date, -1);
69
+ return sameMonth(previous, date) ? previous : seek(date, 1);
70
+ }
71
+ default:
72
+ throw new RangeError(`Unknown adjustment ${JSON.stringify(adjustment)}: expected one of ${ADJUSTMENTS.join(', ')}`);
73
+ }
74
+ },
75
+ addBusinessDays(date, days) {
76
+ if (!Number.isInteger(days))
77
+ throw new RangeError(`Invalid number of days ${days}: expected an integer`);
78
+ parseDate(date);
79
+ const direction = days < 0 ? -1 : 1;
80
+ let current = date;
81
+ for (let remaining = Math.abs(days); remaining > 0; remaining--) {
82
+ current = seek(addDays(current, direction), direction);
83
+ }
84
+ return current;
85
+ },
86
+ businessDaysBetween(start, end) {
87
+ parseDate(start);
88
+ parseDate(end);
89
+ const [first, last] = start <= end ? [start, end] : [end, start];
90
+ let count = 0;
91
+ for (let date = addDays(first, 1); date <= last; date = addDays(date, 1)) {
92
+ if (isBusinessDay(date))
93
+ count++;
94
+ }
95
+ return start <= end || count === 0 ? count : -count;
96
+ },
97
+ };
98
+ }
package/dist/date.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ /** A calendar date in ISO 8601 format, `YYYY-MM-DD`. No time of day, no time zone. */
2
+ export type DateString = string;
3
+ /** ISO 8601 weekday: 1 = Monday … 7 = Sunday. */
4
+ export type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7;
5
+ export declare function isLeapYear(year: number): boolean;
6
+ /** Number of days in a month (`month` is 1–12). */
7
+ export declare function daysInMonth(year: number, month: number): number;
8
+ /** Splits a date string into its parts. Throws `RangeError` unless it is a real date in `YYYY-MM-DD` format. */
9
+ export declare function parseDate(date: DateString): {
10
+ year: number;
11
+ month: number;
12
+ day: number;
13
+ };
14
+ /** `true` when `value` is a real calendar date in `YYYY-MM-DD` format. */
15
+ export declare function isValidDate(value: unknown): value is DateString;
16
+ export declare function makeDate(year: number, month: number, day: number): DateString;
17
+ /** Adds (or, with a negative `days`, subtracts) calendar days. */
18
+ export declare function addDays(date: DateString, days: number): DateString;
19
+ /** ISO weekday of a date: 1 = Monday … 7 = Sunday. */
20
+ export declare function weekdayOf(date: DateString): Weekday;
21
+ /**
22
+ * Today's date in a time zone, e.g. `today('America/New_York')`.
23
+ * Defaults to the time zone of the running environment.
24
+ */
25
+ export declare function today(timeZone?: string, now?: Date): DateString;
package/dist/date.js ADDED
@@ -0,0 +1,76 @@
1
+ const MS_PER_DAY = 86_400_000;
2
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
3
+ const pad2 = (value) => String(value).padStart(2, '0');
4
+ export function isLeapYear(year) {
5
+ return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
6
+ }
7
+ /** Number of days in a month (`month` is 1–12). */
8
+ export function daysInMonth(year, month) {
9
+ if (month === 2)
10
+ return isLeapYear(year) ? 29 : 28;
11
+ return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31;
12
+ }
13
+ /** Splits a date string into its parts. Throws `RangeError` unless it is a real date in `YYYY-MM-DD` format. */
14
+ export function parseDate(date) {
15
+ const match = typeof date === 'string' ? ISO_DATE.exec(date) : null;
16
+ if (match) {
17
+ const year = Number(match[1]);
18
+ const month = Number(match[2]);
19
+ const day = Number(match[3]);
20
+ if (month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth(year, month)) {
21
+ return { year, month, day };
22
+ }
23
+ }
24
+ throw new RangeError(`Invalid date ${JSON.stringify(date)}: expected a real date in YYYY-MM-DD format`);
25
+ }
26
+ /** `true` when `value` is a real calendar date in `YYYY-MM-DD` format. */
27
+ export function isValidDate(value) {
28
+ try {
29
+ parseDate(value);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ export function makeDate(year, month, day) {
37
+ const date = `${String(year).padStart(4, '0')}-${pad2(month)}-${pad2(day)}`;
38
+ parseDate(date);
39
+ return date;
40
+ }
41
+ /** Days since 1970-01-01. Plain integer math, so no time zone or DST can shift a date. */
42
+ function toDayNumber(date) {
43
+ const { year, month, day } = parseDate(date);
44
+ const time = new Date(0);
45
+ time.setUTCFullYear(year, month - 1, day);
46
+ return Math.round(time.getTime() / MS_PER_DAY);
47
+ }
48
+ function fromDayNumber(dayNumber) {
49
+ const time = new Date(dayNumber * MS_PER_DAY);
50
+ return makeDate(time.getUTCFullYear(), time.getUTCMonth() + 1, time.getUTCDate());
51
+ }
52
+ /** Adds (or, with a negative `days`, subtracts) calendar days. */
53
+ export function addDays(date, days) {
54
+ if (!Number.isInteger(days))
55
+ throw new RangeError(`Invalid number of days ${days}: expected an integer`);
56
+ return fromDayNumber(toDayNumber(date) + days);
57
+ }
58
+ /** ISO weekday of a date: 1 = Monday … 7 = Sunday. */
59
+ export function weekdayOf(date) {
60
+ // 1970-01-01 was a Thursday (ISO weekday 4).
61
+ return ((((toDayNumber(date) + 3) % 7) + 7) % 7) + 1;
62
+ }
63
+ /**
64
+ * Today's date in a time zone, e.g. `today('America/New_York')`.
65
+ * Defaults to the time zone of the running environment.
66
+ */
67
+ export function today(timeZone, now = new Date()) {
68
+ const parts = new Intl.DateTimeFormat('en-US', {
69
+ timeZone,
70
+ year: 'numeric',
71
+ month: '2-digit',
72
+ day: '2-digit',
73
+ }).formatToParts(now);
74
+ const part = (type) => parts.find((p) => p.type === type)?.value ?? '';
75
+ return `${part('year')}-${part('month')}-${part('day')}`;
76
+ }
@@ -0,0 +1,8 @@
1
+ export { addDays, daysInMonth, isLeapYear, isValidDate, today, weekdayOf } from './date.js';
2
+ export type { DateString, Weekday } from './date.js';
3
+ export { createCalendar } from './calendar.js';
4
+ export type { Adjustment, Calendar, CalendarOptions } from './calendar.js';
5
+ export { nextDue, occurrences } from './rules.js';
6
+ export type { BusinessDayOfMonthRule, DayOfMonthRule, Occurrence, OccurrenceOptions, Rule, WeekdayOfMonthRule, WeeklyRule, } from './rules.js';
7
+ export { reminderDates } from './reminders.js';
8
+ export type { ReminderOptions } from './reminders.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { addDays, daysInMonth, isLeapYear, isValidDate, today, weekdayOf } from './date.js';
2
+ export { createCalendar } from './calendar.js';
3
+ export { nextDue, occurrences } from './rules.js';
4
+ export { reminderDates } from './reminders.js';
@@ -0,0 +1,13 @@
1
+ import { type Calendar } from './calendar.js';
2
+ import { type DateString } from './date.js';
3
+ export interface ReminderOptions {
4
+ /** `'businessDays'` (default) skips weekends and holidays when counting back; `'calendarDays'` counts every day. */
5
+ unit?: 'businessDays' | 'calendarDays';
6
+ /** Defaults to a calendar with Saturday and Sunday as weekend days and no holidays. */
7
+ calendar?: Calendar;
8
+ }
9
+ /**
10
+ * The dates on which to remind someone of a deadline, sorted and without duplicates.
11
+ * Each offset is how many days before `due` a reminder goes out; `0` is the due date itself.
12
+ */
13
+ export declare function reminderDates(due: DateString, offsets: readonly number[], options?: ReminderOptions): DateString[];
@@ -0,0 +1,22 @@
1
+ import { createCalendar } from './calendar.js';
2
+ import { addDays, parseDate } from './date.js';
3
+ /**
4
+ * The dates on which to remind someone of a deadline, sorted and without duplicates.
5
+ * Each offset is how many days before `due` a reminder goes out; `0` is the due date itself.
6
+ */
7
+ export function reminderDates(due, offsets, options = {}) {
8
+ parseDate(due);
9
+ const unit = options.unit ?? 'businessDays';
10
+ if (unit !== 'businessDays' && unit !== 'calendarDays') {
11
+ throw new RangeError(`Unknown unit ${JSON.stringify(unit)}: expected "businessDays" or "calendarDays"`);
12
+ }
13
+ const calendar = options.calendar ?? createCalendar();
14
+ const dates = new Set();
15
+ for (const offset of offsets) {
16
+ if (!Number.isInteger(offset) || offset < 0) {
17
+ throw new RangeError(`Invalid offset ${offset}: expected a non-negative integer`);
18
+ }
19
+ dates.add(unit === 'businessDays' ? calendar.addBusinessDays(due, -offset) : addDays(due, -offset));
20
+ }
21
+ return [...dates].sort();
22
+ }
@@ -0,0 +1,57 @@
1
+ import { type Adjustment, type Calendar } from './calendar.js';
2
+ import { type DateString, type Weekday } from './date.js';
3
+ interface MonthlyRuleBase {
4
+ /** Only these months (1–12). Use `[3, 6, 9, 12]` for quarterly or `[4]` for yearly deadlines. */
5
+ months?: readonly number[];
6
+ }
7
+ /** A fixed day of the month, e.g. "every 20th". Days past the end of a month use its last day. */
8
+ export interface DayOfMonthRule extends MonthlyRuleBase {
9
+ type: 'dayOfMonth';
10
+ day: number | 'last';
11
+ adjust?: Adjustment;
12
+ }
13
+ /** The nth weekday of the month, e.g. "second Tuesday" (`nth: 2`) or "last Friday" (`nth: -1`). */
14
+ export interface WeekdayOfMonthRule extends MonthlyRuleBase {
15
+ type: 'weekdayOfMonth';
16
+ weekday: Weekday;
17
+ /** 1 to 5 counts from the start of the month, -1 to -5 from the end. Months without it are skipped. */
18
+ nth: number;
19
+ adjust?: Adjustment;
20
+ }
21
+ /** The nth business day of the month, e.g. "5th business day" (`n: 5`) or "last business day" (`n: -1`). */
22
+ export interface BusinessDayOfMonthRule extends MonthlyRuleBase {
23
+ type: 'businessDayOfMonth';
24
+ /** Positive counts from the start of the month, negative from the end. */
25
+ n: number;
26
+ }
27
+ /** Every week on the same weekday. */
28
+ export interface WeeklyRule {
29
+ type: 'weekly';
30
+ weekday: Weekday;
31
+ adjust?: Adjustment;
32
+ }
33
+ export type Rule = DayOfMonthRule | WeekdayOfMonthRule | BusinessDayOfMonthRule | WeeklyRule;
34
+ export interface Occurrence {
35
+ /** The date the rule names, before any business-day adjustment. */
36
+ nominal: DateString;
37
+ /** The date the deadline actually falls on. */
38
+ due: DateString;
39
+ }
40
+ export interface OccurrenceOptions {
41
+ /** First date to include (compared against `due`). */
42
+ from: DateString;
43
+ /** Last date to include (compared against `due`). */
44
+ to?: DateString;
45
+ /** Maximum number of occurrences to return. */
46
+ count?: number;
47
+ /** Defaults to a calendar with Saturday and Sunday as weekend days and no holidays. */
48
+ calendar?: Calendar;
49
+ }
50
+ /** Due dates of a rule from `from` on, until `to` or `count` is reached (at least one of them is required). */
51
+ export declare function occurrences(rule: Rule, options: OccurrenceOptions): Occurrence[];
52
+ /** The first occurrence due on or after `from`, or `undefined` if there is none within 100 years. */
53
+ export declare function nextDue(rule: Rule, options: {
54
+ from: DateString;
55
+ calendar?: Calendar;
56
+ }): Occurrence | undefined;
57
+ export {};
package/dist/rules.js ADDED
@@ -0,0 +1,141 @@
1
+ import { ADJUSTMENTS, createCalendar } from './calendar.js';
2
+ import { addDays, daysInMonth, makeDate, parseDate, weekdayOf } from './date.js';
3
+ /** Generation stops after this many months (or weeks) so a rule that rarely matches cannot loop forever. */
4
+ const MAX_MONTHS = 1200;
5
+ const MAX_WEEKS = 5220;
6
+ /** Due dates of a rule from `from` on, until `to` or `count` is reached (at least one of them is required). */
7
+ export function occurrences(rule, options) {
8
+ const { from, to, count } = options;
9
+ parseDate(from);
10
+ if (to !== undefined)
11
+ parseDate(to);
12
+ if (to === undefined && count === undefined)
13
+ throw new RangeError('Pass `to`, `count`, or both');
14
+ if (count !== undefined && (!Number.isInteger(count) || count < 0)) {
15
+ throw new RangeError(`Invalid count ${count}: expected a non-negative integer`);
16
+ }
17
+ validateRule(rule);
18
+ const calendar = options.calendar ?? createCalendar();
19
+ const result = [];
20
+ if (count === 0)
21
+ return result;
22
+ const nominals = rule.type === 'weekly' ? weeklyNominals(rule, from) : monthlyNominals(rule, from, calendar);
23
+ for (const nominal of nominals) {
24
+ const due = rule.type === 'businessDayOfMonth' ? nominal : calendar.adjust(nominal, rule.adjust ?? 'none');
25
+ // Adjustments never reorder dates, so the first due date past `to` ends the search.
26
+ if (to !== undefined && due > to)
27
+ break;
28
+ if (due < from)
29
+ continue;
30
+ result.push({ nominal, due });
31
+ if (result.length === count)
32
+ break;
33
+ }
34
+ return result;
35
+ }
36
+ /** The first occurrence due on or after `from`, or `undefined` if there is none within 100 years. */
37
+ export function nextDue(rule, options) {
38
+ return occurrences(rule, { ...options, count: 1 })[0];
39
+ }
40
+ function* monthlyNominals(rule, from, calendar) {
41
+ const months = rule.months ? new Set(rule.months) : undefined;
42
+ // Start a month early: a date late last month may be adjusted forward into range.
43
+ let { year, month } = parseDate(from);
44
+ month -= 1;
45
+ if (month === 0) {
46
+ month = 12;
47
+ year -= 1;
48
+ }
49
+ for (let i = 0; i < MAX_MONTHS; i++) {
50
+ if (!months || months.has(month)) {
51
+ const nominal = nominalInMonth(rule, year, month, calendar);
52
+ if (nominal)
53
+ yield nominal;
54
+ }
55
+ month += 1;
56
+ if (month === 13) {
57
+ month = 1;
58
+ year += 1;
59
+ }
60
+ }
61
+ }
62
+ function* weeklyNominals(rule, from) {
63
+ // Start a week early: a date late last week may be adjusted forward into range.
64
+ const daysSinceWeekday = (weekdayOf(from) - rule.weekday + 7) % 7;
65
+ let nominal = addDays(from, -daysSinceWeekday - 7);
66
+ for (let i = 0; i < MAX_WEEKS; i++) {
67
+ yield nominal;
68
+ nominal = addDays(nominal, 7);
69
+ }
70
+ }
71
+ function nominalInMonth(rule, year, month, calendar) {
72
+ const lastDay = daysInMonth(year, month);
73
+ switch (rule.type) {
74
+ case 'dayOfMonth':
75
+ return makeDate(year, month, rule.day === 'last' ? lastDay : Math.min(rule.day, lastDay));
76
+ case 'weekdayOfMonth': {
77
+ let day;
78
+ if (rule.nth > 0) {
79
+ const firstWeekday = weekdayOf(makeDate(year, month, 1));
80
+ day = 1 + ((rule.weekday - firstWeekday + 7) % 7) + 7 * (rule.nth - 1);
81
+ }
82
+ else {
83
+ const lastWeekday = weekdayOf(makeDate(year, month, lastDay));
84
+ day = lastDay - ((lastWeekday - rule.weekday + 7) % 7) + 7 * (rule.nth + 1);
85
+ }
86
+ return day >= 1 && day <= lastDay ? makeDate(year, month, day) : undefined;
87
+ }
88
+ case 'businessDayOfMonth': {
89
+ const step = rule.n > 0 ? 1 : -1;
90
+ let seen = 0;
91
+ for (let day = step > 0 ? 1 : lastDay; day >= 1 && day <= lastDay; day += step) {
92
+ const date = makeDate(year, month, day);
93
+ if (calendar.isBusinessDay(date) && ++seen === Math.abs(rule.n))
94
+ return date;
95
+ }
96
+ return undefined;
97
+ }
98
+ }
99
+ }
100
+ function validateRule(rule) {
101
+ const fail = (message) => {
102
+ throw new RangeError(`Invalid ${rule?.type ?? 'rule'} rule: ${message}`);
103
+ };
104
+ const isWeekday = (value) => Number.isInteger(value) && value >= 1 && value <= 7;
105
+ if ('months' in rule && rule.months !== undefined) {
106
+ if (rule.months.length === 0)
107
+ fail('`months` must not be empty');
108
+ for (const month of rule.months) {
109
+ if (!Number.isInteger(month) || month < 1 || month > 12)
110
+ fail(`month ${month} is not 1–12`);
111
+ }
112
+ }
113
+ if ('adjust' in rule && rule.adjust !== undefined && !ADJUSTMENTS.includes(rule.adjust)) {
114
+ fail(`unknown adjustment ${JSON.stringify(rule.adjust)}`);
115
+ }
116
+ switch (rule?.type) {
117
+ case 'dayOfMonth':
118
+ if (rule.day !== 'last' && !(Number.isInteger(rule.day) && rule.day >= 1 && rule.day <= 31)) {
119
+ fail('`day` must be 1–31 or "last"');
120
+ }
121
+ return;
122
+ case 'weekdayOfMonth':
123
+ if (!isWeekday(rule.weekday))
124
+ fail('`weekday` must be 1 (Monday) to 7 (Sunday)');
125
+ if (!Number.isInteger(rule.nth) || rule.nth === 0 || rule.nth < -5 || rule.nth > 5) {
126
+ fail('`nth` must be 1 to 5 or -1 to -5');
127
+ }
128
+ return;
129
+ case 'businessDayOfMonth':
130
+ if (!Number.isInteger(rule.n) || rule.n === 0 || Math.abs(rule.n) > 23) {
131
+ fail('`n` must be 1 to 23 or -1 to -23');
132
+ }
133
+ return;
134
+ case 'weekly':
135
+ if (!isWeekday(rule.weekday))
136
+ fail('`weekday` must be 1 (Monday) to 7 (Sunday)');
137
+ return;
138
+ default:
139
+ throw new RangeError(`Unknown rule type ${JSON.stringify(rule?.type)}`);
140
+ }
141
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@contalert/duedates",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Recurring deadlines with business days, holidays and reminders. Zero dependencies, time-zone safe.",
5
+ "keywords": [
6
+ "deadline",
7
+ "due-date",
8
+ "business-days",
9
+ "working-days",
10
+ "holidays",
11
+ "recurring",
12
+ "schedule",
13
+ "reminders",
14
+ "calendar",
15
+ "modified-following"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "ContAlert Sistemas Ltda <contato@contalert.com.br> (https://contalert.com.br)",
19
+ "homepage": "https://github.com/contalert/duedates#readme",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/contalert/duedates.git"
23
+ },
24
+ "bugs": "https://github.com/contalert/duedates/issues",
25
+ "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ },
32
+ "main": "./dist/index.js",
33
+ "types": "./dist/index.d.ts",
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "sideEffects": false,
38
+ "engines": {
39
+ "node": ">=20"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "vitest run",
45
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
46
+ },
47
+ "devDependencies": {
48
+ "typescript": "^7.0.2",
49
+ "vitest": "^5.1.0"
50
+ }
51
+ }