diff --git a/src/schedule.js b/src/schedule.js index 862dc55..fe6fa0d 100644 --- a/src/schedule.js +++ b/src/schedule.js @@ -23,5 +23,64 @@ * 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 (!Number.isInteger(interval) || interval < 1) { + throw new RangeError('interval must be a positive integer'); + } + if (count === undefined && until === undefined) { + throw new RangeError('either count or until is required'); + } + if (count !== undefined && (!Number.isInteger(count) || count < 1)) { + throw new RangeError('count must be a positive integer'); + } + const [y, m, d] = parseDate(start); + if (until !== undefined) parseDate(until); + const skipped = new Set(skip); + + const result = []; + for (let i = 0; count === undefined || result.length < count; i++) { + let date; + if (every === 'week') { + const dt = new Date(0); + dt.setUTCFullYear(y, m - 1, d + 7 * interval * i); + date = format(dt); + } else { + const total = m - 1 + interval * i; + const year = y + Math.floor(total / 12); + const month = total % 12; + date = formatParts(year, month + 1, Math.min(d, daysInMonth(year, month))); + } + if (until !== undefined && date > until) break; + if (!skipped.has(date)) result.push(date); + } + return result; +} + +function daysInMonth(year, monthIndex) { + const dt = new Date(0); + dt.setUTCFullYear(year, monthIndex + 1, 0); + return dt.getUTCDate(); +} + +function formatParts(year, month, day) { + const p = (n, w) => String(n).padStart(w, '0'); + return `${p(year, 4)}-${p(month, 2)}-${p(day, 2)}`; +} + +function format(dt) { + return formatParts(dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate()); +} + +function parseDate(str) { + const match = typeof str === 'string' && /^(\d{4})-(\d{2})-(\d{2})$/.exec(str); + if (!match) throw new RangeError(`invalid date: ${str}`); + const [y, m, d] = match.slice(1).map(Number); + if (m < 1 || m > 12 || d < 1 || d > daysInMonth(y, m - 1)) { + throw new RangeError(`invalid date: ${str}`); + } + return [y, m, d]; }