diff --git a/src/schedule.js b/src/schedule.js index 862dc55..405e22b 100644 --- a/src/schedule.js +++ b/src/schedule.js @@ -23,5 +23,66 @@ * calendar date in 'YYYY-MM-DD' form. */ export function billingDates(start, rule) { - throw new Error('not implemented'); + if (rule === null || typeof rule !== 'object') { + throw new RangeError('rule must be an object'); + } + const { every, until, skip = [] } = rule; + const interval = rule.interval === undefined ? 1 : rule.interval; + const count = rule.count === null ? undefined : rule.count; + const hasUntil = until !== undefined && until !== null; + + 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'); + if (count !== undefined && !isPositiveInt(count)) { + throw new RangeError('count must be a positive integer'); + } + if (count === undefined && !hasUntil) { + throw new RangeError('either count or until is required'); + } + const [y, m, d] = parseDate(start, 'start'); + const untilStr = hasUntil ? formatDate(...parseDate(until, 'until')) : null; + const skipped = new Set(skip); + + const result = []; + for (let i = 0; count === undefined || result.length < count; i++) { + let date; + if (every === 'week') { + const t = new Date(0); + t.setUTCFullYear(y, m - 1, d + 7 * interval * i); + date = formatDate(t.getUTCFullYear(), t.getUTCMonth() + 1, t.getUTCDate()); + } else { + const total = y * 12 + (m - 1) + interval * i; + const year = Math.floor(total / 12); + const month = (total % 12) + 1; + date = formatDate(year, month, Math.min(d, daysInMonth(year, month))); + } + if (untilStr !== null && date > untilStr) break; + if (!skipped.has(date)) result.push(date); + } + return result; +} + +function isPositiveInt(n) { + return Number.isInteger(n) && n > 0; +} + +function daysInMonth(year, month) { + const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + return month === 2 ? (leap ? 29 : 28) : [4, 6, 9, 11].includes(month) ? 30 : 31; +} + +function parseDate(s, name) { + const match = typeof s === 'string' && /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); + if (match) { + const [y, m, d] = match.slice(1).map(Number); + if (m >= 1 && m <= 12 && d >= 1 && d <= daysInMonth(y, m)) return [y, m, d]; + } + throw new RangeError(`${name} must be a real date in YYYY-MM-DD form`); +} + +function formatDate(y, m, d) { + const p = (n, w) => String(n).padStart(w, '0'); + return `${p(y, 4)}-${p(m, 2)}-${p(d, 2)}`; }