diff --git a/src/schedule.js b/src/schedule.js index 862dc55..d53e870 100644 --- a/src/schedule.js +++ b/src/schedule.js @@ -23,5 +23,57 @@ * 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 ?? {}; + + const parse = (s, name) => { + const m = typeof s === 'string' && /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); + if (!m) throw new RangeError(`${name} must be a 'YYYY-MM-DD' date`); + const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]; + if (mo < 1 || mo > 12 || d < 1 || d > daysInMonth(y, mo - 1)) { + throw new RangeError(`${name} is not a real calendar date`); + } + return { y, mo: mo - 1, d }; + }; + const pad = (n, w) => String(n).padStart(w, '0'); + const format = (y, mo, d) => `${pad(y, 4)}-${pad(mo + 1, 2)}-${pad(d, 2)}`; + const isPositiveInt = (n) => Number.isInteger(n) && n > 0; + + if (every !== 'week' && every !== 'month') { + throw new RangeError("every must be 'week' or 'month'"); + } + if (!isPositiveInt(interval)) throw new RangeError('interval must be a positive integer'); + const hasCount = count !== undefined && count !== null; + const hasUntil = until !== undefined && until !== null; + if (!hasCount && !hasUntil) throw new RangeError('count or until is required'); + if (hasCount && !isPositiveInt(count)) throw new RangeError('count must be a positive integer'); + + const first = parse(start, 'start'); + if (hasUntil) parse(until, 'until'); + const skipped = new Set(skip); + + const result = []; + for (let k = 0; !hasCount || result.length < count; k++) { + let y, mo, d; + if (every === 'week') { + const t = new Date(0); + t.setUTCFullYear(first.y, first.mo, first.d + 7 * interval * k); + [y, mo, d] = [t.getUTCFullYear(), t.getUTCMonth(), t.getUTCDate()]; + } else { + const total = first.mo + interval * k; + y = first.y + Math.floor(total / 12); + mo = total % 12; + d = Math.min(first.d, daysInMonth(y, mo)); + } + if (y > 9999) break; + const date = format(y, mo, d); + if (hasUntil && date > until) break; + if (!skipped.has(date)) result.push(date); + } + return result; +} + +function daysInMonth(y, mo) { + const t = new Date(0); + t.setUTCFullYear(y, mo + 1, 0); + return t.getUTCDate(); }