diff --git a/src/schedule.js b/src/schedule.js index 862dc55..6d71c01 100644 --- a/src/schedule.js +++ b/src/schedule.js @@ -23,5 +23,63 @@ * calendar date in 'YYYY-MM-DD' form. */ export function billingDates(start, rule) { - throw new Error('not implemented'); + const { every, interval = 1, count, until, skip = [] } = rule ?? {}; + + if (every !== 'week' && every !== 'month') throw new RangeError(`every must be 'week' or 'month', got ${every}`); + if (!isPositiveInt(interval)) throw new RangeError(`interval must be a positive integer, got ${interval}`); + if (count == null && until == null) throw new RangeError('give count, until, or both'); + if (count != null && !isPositiveInt(count)) throw new RangeError(`count must be a positive integer, got ${count}`); + + const first = parseDate(start, 'start'); + if (until != null) parseDate(until, 'until'); + const skipped = new Set(skip); + + const result = []; + for (let i = 0; count == null || result.length < count; i++) { + const date = every === 'week' ? addWeeks(first, i * interval) : addMonths(first, i * interval); + // 'YYYY-MM-DD' strings sort chronologically. + if (until != null && date > until) break; + if (!skipped.has(date)) result.push(date); + } + return result; +} + +function isPositiveInt(n) { + return Number.isInteger(n) && n > 0; +} + +function daysInMonth(year, month) { + // month is 1-12 + if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28; + return [4, 6, 9, 11].includes(month) ? 30 : 31; +} + +function parseDate(str, name) { + const m = typeof str === 'string' && /^(\d{4})-(\d{2})-(\d{2})$/.exec(str); + if (m) { + const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])]; + if (month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth(year, month)) { + return { year, month, day }; + } + } + throw new RangeError(`${name} must be a real calendar date in YYYY-MM-DD form, got ${str}`); +} + +function format({ year, month, day }) { + const p = (n, w) => String(n).padStart(w, '0'); + return `${p(year, 4)}-${p(month, 2)}-${p(day, 2)}`; +} + +// Always computed from the start date, so a clamped day never carries into later months. +function addMonths({ year, month, day }, months) { + const index = year * 12 + (month - 1) + months; + const y = Math.floor(index / 12); + const m = (index % 12) + 1; + return format({ year: y, month: m, day: Math.min(day, daysInMonth(y, m)) }); +} + +function addWeeks({ year, month, day }, weeks) { + const d = new Date(0); + d.setUTCFullYear(year, month - 1, day + weeks * 7); + return format({ year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() }); } diff --git a/tests/schedule.test.js b/tests/schedule.test.js index ee3d88f..a71a2a6 100644 --- a/tests/schedule.test.js +++ b/tests/schedule.test.js @@ -2,6 +2,53 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { billingDates } from '../src/schedule.js'; +const m = (start, rule) => billingDates(start, { every: 'month', ...rule }); + test('monthly, mid-month start', () => { assert.deepEqual(billingDates('2026-01-15', { every: 'month', count: 3 }), ['2026-01-15', '2026-02-15', '2026-03-15']); }); + +test('month-end clamps then returns to original day', () => { + assert.deepEqual(m('2026-01-31', { count: 4 }), ['2026-01-31', '2026-02-28', '2026-03-31', '2026-04-30']); +}); + +test('leap year february', () => { + assert.deepEqual(m('2028-01-31', { count: 2 }), ['2028-01-31', '2028-02-29']); +}); + +test('monthly interval across year boundary', () => { + assert.deepEqual(m('2026-11-30', { interval: 3, count: 3 }), ['2026-11-30', '2027-02-28', '2027-05-30']); +}); + +test('weekly with interval, across month and year', () => { + assert.deepEqual(billingDates('2026-12-20', { every: 'week', interval: 2, count: 3 }), ['2026-12-20', '2027-01-03', '2027-01-17']); +}); + +test('until is inclusive and combines with count', () => { + assert.deepEqual(m('2026-01-15', { until: '2026-03-15' }), ['2026-01-15', '2026-02-15', '2026-03-15']); + assert.deepEqual(m('2026-01-15', { until: '2026-03-14' }), ['2026-01-15', '2026-02-15']); + assert.deepEqual(m('2026-01-15', { count: 2, until: '2026-12-15' }), ['2026-01-15', '2026-02-15']); + assert.deepEqual(m('2026-01-15', { until: '2026-01-01' }), []); +}); + +test('skipped dates are omitted and do not count', () => { + assert.deepEqual(m('2026-01-15', { count: 3, skip: ['2026-01-15', '2026-03-15'] }), ['2026-02-15', '2026-04-15', '2026-05-15']); + assert.deepEqual(m('2026-01-15', { until: '2026-03-15', skip: ['2026-02-15'] }), ['2026-01-15', '2026-03-15']); +}); + +test('invalid input throws RangeError', () => { + const bad = [ + ['2026-01-15', { every: 'month' }], + ['2026-01-15', { every: 'day', count: 1 }], + ['2026-01-15', { every: 'month', count: 0 }], + ['2026-01-15', { every: 'month', count: 1.5 }], + ['2026-01-15', { every: 'month', count: 1, interval: 0 }], + ['2026-01-15', { every: 'month', count: 1, interval: -1 }], + ['2026-02-30', { every: 'month', count: 1 }], + ['2026-1-15', { every: 'month', count: 1 }], + ['2026-01-15', { every: 'month', until: '2026-13-01' }], + ['2027-02-29', { every: 'week', count: 1 }], + [undefined, { every: 'week', count: 1 }], + ]; + for (const [s, r] of bad) assert.throws(() => billingDates(s, r), RangeError, JSON.stringify([s, r])); +});