UNPKG

chronos-ts

Version:

A comprehensive TypeScript library for date and time manipulation, inspired by Carbon PHP. Features immutable API, intervals, periods, timezones, and i18n support.

1,063 lines (1,062 loc) 34.6 kB
"use strict"; /** * ChronosPeriod - Date range iteration * @module ChronosPeriod */ Object.defineProperty(exports, "__esModule", { value: true }); exports.ChronosPeriod = void 0; const utils_1 = require("../utils"); const locales_1 = require("../locales"); const chronos_1 = require("./chronos"); const interval_1 = require("./interval"); // ============================================================================ // ChronosPeriod Class // ============================================================================ /** * ChronosPeriod - Represents a date range with iteration capabilities * * Inspired by CarbonPeriod, this class provides powerful date range * iteration with support for various interval types, filters, and * recurrence patterns. * * @example * ```typescript * // Basic iteration * const period = ChronosPeriod.create('2024-01-01', '2024-01-31'); * for (const date of period) { * console.log(date.format('YYYY-MM-DD')); * } * * // With custom interval * const weekly = ChronosPeriod.create('2024-01-01', '2024-03-31') * .setInterval(ChronosInterval.weeks(1)); * * // With filters * const weekdays = period.filter(date => date.dayOfWeek < 6); * * // Recurrence * const recur = ChronosPeriod.recur('2024-01-01') * .every(1, 'month') * .times(12); * ``` */ class ChronosPeriod { // ============================================================================ // Constructor // ============================================================================ /** * Create a new ChronosPeriod */ constructor(start, end, interval, options = {}) { var _a, _b, _c; this._start = chronos_1.Chronos.parse(start); this._end = end !== undefined ? chronos_1.Chronos.parse(end) : null; this._interval = interval instanceof interval_1.ChronosInterval ? interval : interval_1.ChronosInterval.create(interval || { days: 1 }); // Validate interval is not zero to prevent infinite loops if (this._interval.isZero()) { throw new Error('ChronosPeriod: Interval cannot be zero'); } // Validate interval is not negative if (this._interval.isNegative()) { throw new Error('ChronosPeriod: Interval cannot be negative'); } // Warn about potentially large iterations (optional safety check) if (this._end !== null) { const durationMs = Math.abs(this._end.valueOf() - this._start.valueOf()); const intervalMs = Math.abs(this._interval.totalMilliseconds()); if (intervalMs > 0) { const estimatedIterations = durationMs / intervalMs; // Warn if period would generate more than 1 million iterations if (estimatedIterations > 1000000) { console.warn(`ChronosPeriod: Large number of iterations detected (~${Math.floor(estimatedIterations).toLocaleString()}). ` + `This may cause performance issues. Consider using a larger interval or setting a recurrence limit.`); } // Hard limit: throw error if more than 10 million iterations if (estimatedIterations > 10000000) { throw new Error(`ChronosPeriod: Period would generate ~${Math.floor(estimatedIterations).toLocaleString()} iterations, ` + `which exceeds the safety limit of 10 million. Use a larger interval or set explicit recurrence limits.`); } } } this._recurrences = null; this._options = { excludeStart: (_a = options.excludeStart) !== null && _a !== void 0 ? _a : false, excludeEnd: (_b = options.excludeEnd) !== null && _b !== void 0 ? _b : false, immutable: (_c = options.immutable) !== null && _c !== void 0 ? _c : true, }; this._filters = []; this._current = 0; this._locale = (0, locales_1.getLocale)('en'); } // ============================================================================ // Static Factory Methods // ============================================================================ /** * Create a period between two dates */ static create(start, end, interval) { return new ChronosPeriod(start, end, interval); } /** * Create a period from a start date with recurrences */ static recur(start, interval) { return new ChronosPeriod(start, undefined, interval); } /** * Create a period for a specific number of days */ static days(start, count) { const startDate = chronos_1.Chronos.parse(start); const endDate = startDate.addDays(count - 1); return new ChronosPeriod(startDate, endDate, { days: 1 }); } /** * Create a period for a specific number of weeks */ static weeks(start, count) { const startDate = chronos_1.Chronos.parse(start); const endDate = startDate.addWeeks(count); return new ChronosPeriod(startDate, endDate, { weeks: 1 }); } /** * Create a period for a specific number of months */ static months(start, count) { const startDate = chronos_1.Chronos.parse(start); const endDate = startDate.addMonths(count); return new ChronosPeriod(startDate, endDate, { months: 1 }); } /** * Create a period for a specific number of years */ static years(start, count) { const startDate = chronos_1.Chronos.parse(start); const endDate = startDate.addYears(count); return new ChronosPeriod(startDate, endDate, { years: 1 }); } /** * Create a period for the current month */ static currentMonth() { const now = chronos_1.Chronos.now(); return new ChronosPeriod(now.startOf('month'), now.endOf('month'), { days: 1, }); } /** * Create a period for the current year */ static currentYear() { const now = chronos_1.Chronos.now(); return new ChronosPeriod(now.startOf('year'), now.endOf('year'), { days: 1, }); } /** * Create a period for the current week */ static currentWeek() { const now = chronos_1.Chronos.now(); return new ChronosPeriod(now.startOf('week'), now.endOf('week'), { days: 1, }); } /** * Create a period for the current quarter */ static currentQuarter() { const now = chronos_1.Chronos.now(); return new ChronosPeriod(now.startOf('quarter'), now.endOf('quarter'), { days: 1, }); } // ============================================================================ // Convenience Aliases (thisWeek, thisMonth, lastWeek, etc.) // ============================================================================ /** * Alias for currentWeek() */ static thisWeek() { return ChronosPeriod.currentWeek(); } /** * Alias for currentMonth() */ static thisMonth() { return ChronosPeriod.currentMonth(); } /** * Alias for currentYear() */ static thisYear() { return ChronosPeriod.currentYear(); } /** * Alias for currentQuarter() */ static thisQuarter() { return ChronosPeriod.currentQuarter(); } /** * Create a period for the previous week */ static lastWeek() { const now = chronos_1.Chronos.now().subtract({ weeks: 1 }); return new ChronosPeriod(now.startOf('week'), now.endOf('week'), { days: 1, }); } /** * Create a period for the previous month */ static lastMonth() { const now = chronos_1.Chronos.now().subtract({ months: 1 }); return new ChronosPeriod(now.startOf('month'), now.endOf('month'), { days: 1, }); } /** * Create a period for the previous year */ static lastYear() { const now = chronos_1.Chronos.now().subtract({ years: 1 }); return new ChronosPeriod(now.startOf('year'), now.endOf('year'), { days: 1, }); } /** * Create a period for the previous quarter */ static lastQuarter() { const now = chronos_1.Chronos.now().subtract({ months: 3 }); return new ChronosPeriod(now.startOf('quarter'), now.endOf('quarter'), { days: 1, }); } /** * Create a period for the next week */ static nextWeek() { const now = chronos_1.Chronos.now().add({ weeks: 1 }); return new ChronosPeriod(now.startOf('week'), now.endOf('week'), { days: 1, }); } /** * Create a period for the next month */ static nextMonth() { const now = chronos_1.Chronos.now().add({ months: 1 }); return new ChronosPeriod(now.startOf('month'), now.endOf('month'), { days: 1, }); } /** * Create a period for the next year */ static nextYear() { const now = chronos_1.Chronos.now().add({ years: 1 }); return new ChronosPeriod(now.startOf('year'), now.endOf('year'), { days: 1, }); } /** * Create a period for the next quarter */ static nextQuarter() { const now = chronos_1.Chronos.now().add({ months: 3 }); return new ChronosPeriod(now.startOf('quarter'), now.endOf('quarter'), { days: 1, }); } /** * Create a period between two dates (alias for create) */ static between(start, end, interval) { return ChronosPeriod.create(start, end, interval); } /** * Create a period from an ISO 8601 repeating interval string * @example * ```typescript * ChronosPeriod.fromISO('R5/2024-01-01/P1D') // 5 recurrences, daily from 2024-01-01 * ChronosPeriod.fromISO('2024-01-01/2024-01-31') // Date range * ChronosPeriod.fromISO('2024-01-01/P1M') // From date with duration * ``` */ static fromISO(iso) { // Pattern for repeating interval: R[n]/start/duration or R[n]/start/end const repeatMatch = iso.match(/^R(\d*)\/([\d\-T:Z]+)\/(P.+|[\d\-T:Z]+)$/); if (repeatMatch) { const recurrences = repeatMatch[1] ? parseInt(repeatMatch[1], 10) : Infinity; const start = chronos_1.Chronos.parse(repeatMatch[2]); const durationOrEnd = repeatMatch[3]; if (durationOrEnd.startsWith('P')) { const interval = interval_1.ChronosInterval.fromISO(durationOrEnd); return new ChronosPeriod(start, undefined, interval).times(recurrences); } else { const end = chronos_1.Chronos.parse(durationOrEnd); return new ChronosPeriod(start, end).times(recurrences); } } // Pattern for date range: start/end or start/duration const rangeMatch = iso.match(/^([\d\-T:Z]+)\/(P.+|[\d\-T:Z]+)$/); if (rangeMatch) { const start = chronos_1.Chronos.parse(rangeMatch[1]); const durationOrEnd = rangeMatch[2]; if (durationOrEnd.startsWith('P')) { const interval = interval_1.ChronosInterval.fromISO(durationOrEnd); const end = start.add(interval.toDuration()); return new ChronosPeriod(start, end, interval); } else { const end = chronos_1.Chronos.parse(durationOrEnd); return new ChronosPeriod(start, end); } } throw new Error(`Invalid ISO 8601 period: ${iso}`); } // ============================================================================ // Getters // ============================================================================ /** * Get the start date */ get start() { return this._start; } /** * Get the end date */ get end() { return this._end; } /** * Get the interval */ get interval() { return this._interval; } /** * Get the number of recurrences */ get recurrences() { return this._recurrences; } /** * Check if the period includes the start boundary */ get includesStart() { return !this._options.excludeStart; } /** * Check if the period includes the end boundary */ get includesEnd() { return !this._options.excludeEnd; } /** * Check if the period has an end date */ get hasEnd() { return this._end !== null || this._recurrences !== null; } /** * Check if the period is unbounded */ get isUnbounded() { return this._end === null && this._recurrences === null; } // ============================================================================ // Setters (Fluent Interface) // ============================================================================ /** * Set the start date */ setStart(start) { const period = this._cloneForModification(); period._start = chronos_1.Chronos.parse(start); return period; } /** * Set the end date */ setEnd(end) { const period = this._cloneForModification(); period._end = chronos_1.Chronos.parse(end); return period; } /** * Set the interval */ setInterval(interval) { const period = this._cloneForModification(); const newInterval = interval instanceof interval_1.ChronosInterval ? interval : interval_1.ChronosInterval.create(interval); // Validate interval is not zero to prevent infinite loops if (newInterval.isZero()) { throw new Error('ChronosPeriod: Interval cannot be zero'); } // Validate interval is not negative if (newInterval.isNegative()) { throw new Error('ChronosPeriod: Interval cannot be negative'); } period._interval = newInterval; return period; } /** * Set the number of recurrences */ times(count) { const period = this._cloneForModification(); period._recurrences = count; return period; } /** * Set interval by unit */ every(amount, unit) { // Validate amount is positive if (amount <= 0) { throw new Error('ChronosPeriod: Amount must be positive'); } const normalizedUnit = (0, utils_1.normalizeUnit)(unit); const duration = {}; switch (normalizedUnit) { case 'millisecond': duration.milliseconds = amount; break; case 'second': duration.seconds = amount; break; case 'minute': duration.minutes = amount; break; case 'hour': duration.hours = amount; break; case 'day': duration.days = amount; break; case 'week': duration.weeks = amount; break; case 'month': duration.months = amount; break; case 'quarter': duration.months = amount * 3; break; case 'year': duration.years = amount; break; default: duration.days = amount; } return this.setInterval(duration); } /** * Exclude the start boundary */ excludeStart() { const period = this._cloneForModification(); period._options.excludeStart = true; return period; } /** * Exclude the end boundary */ excludeEnd() { const period = this._cloneForModification(); period._options.excludeEnd = true; return period; } /** * Include the start boundary */ includeStart() { const period = this._cloneForModification(); period._options.excludeStart = false; return period; } /** * Include the end boundary */ includeEnd() { const period = this._cloneForModification(); period._options.excludeEnd = false; return period; } // ============================================================================ // Filters // ============================================================================ /** * Add a filter function */ filter(fn) { const period = this._cloneForModification(); period._filters.push(fn); return period; } /** * Filter to only include weekdays */ weekdays() { return this.filter((date) => date.dayOfWeek !== 0 && date.dayOfWeek !== 6); } /** * Alias for weekdays() */ filterWeekdays() { return this.weekdays(); } /** * Filter to only include weekends */ weekends() { return this.filter((date) => date.dayOfWeek === 0 || date.dayOfWeek === 6); } /** * Alias for weekends() */ filterWeekends() { return this.weekends(); } /** * Filter to only include specific days of week */ onlyDays(...days) { return this.filter((date) => days.includes(date.dayOfWeek)); } /** * Filter to exclude specific days of week */ exceptDays(...days) { return this.filter((date) => !days.includes(date.dayOfWeek)); } /** * Filter to only include specific months */ onlyMonths(...months) { return this.filter((date) => months.includes(date.month)); } /** * Filter to exclude specific months */ exceptMonths(...months) { return this.filter((date) => !months.includes(date.month)); } /** * Clear all filters */ clearFilters() { const period = this._cloneForModification(); period._filters = []; return period; } // ============================================================================ // Iteration // ============================================================================ /** * Get all dates in the period as an array */ toArray() { return [...this]; } /** * Iterate over the period */ *[Symbol.iterator]() { let current = this._start.clone(); let count = 0; // Handle excludeStart if (this._options.excludeStart) { current = this._applyInterval(current); count++; } while (this._shouldContinue(current, count)) { // Apply filters if (this._passesFilters(current, count)) { yield this._options.immutable ? current.clone() : current; } current = this._applyInterval(current); count++; } } /** * Apply the interval to a date */ _applyInterval(date) { return date.add(this._interval.toDuration()); } /** * Check if iteration should continue */ _shouldContinue(date, count) { // Check recurrence limit if (this._recurrences !== null && count >= this._recurrences) { return false; } // Check end date if (this._end !== null) { if (this._options.excludeEnd) { return date.isBefore(this._end); } return date.isSameOrBefore(this._end); } // No end - check for unbounded if (this._recurrences === null) { // Prevent infinite iteration if (count > 10000) { throw new Error('ChronosPeriod: Maximum iteration limit reached. Set an end date or recurrence limit.'); } } return true; } /** * Check if a date passes all filters */ _passesFilters(date, key) { return this._filters.every((filter) => filter(date, key)); } /** * Get count of dates in the period */ count() { return this.toArray().length; } /** * Get the first date in the period */ first() { const iterator = this[Symbol.iterator](); const result = iterator.next(); return result.done ? null : result.value; } /** * Get the last date in the period */ last() { let lastDate = null; for (const date of this) { lastDate = date; } return lastDate; } /** * Get a date at a specific index */ nth(index) { let count = 0; for (const date of this) { if (count === index) { return date; } count++; } return null; } /** * Check if a date is within the period */ contains(date) { const target = chronos_1.Chronos.parse(date); for (const d of this) { if (d.isSame(target, 'day')) { return true; } } return false; } /** * For each iteration */ forEach(callback) { let index = 0; for (const date of this) { callback(date, index); index++; } } /** * Map dates to another type */ map(callback) { const result = []; let index = 0; for (const date of this) { result.push(callback(date, index)); index++; } return result; } /** * Reduce dates to a single value */ reduce(callback, initial) { let acc = initial; let index = 0; for (const date of this) { acc = callback(acc, date, index); index++; } return acc; } // ============================================================================ // Range Operations // ============================================================================ /** * Check if two periods overlap */ overlaps(other) { var _a, _b; const thisEnd = (_a = this._end) !== null && _a !== void 0 ? _a : this.last(); const otherEnd = (_b = other._end) !== null && _b !== void 0 ? _b : other.last(); if (!thisEnd || !otherEnd) { return true; // Unbounded periods always overlap } return (this._start.isSameOrBefore(otherEnd) && thisEnd.isSameOrAfter(other._start)); } /** * Get the intersection of two periods */ intersect(other) { var _a, _b; if (!this.overlaps(other)) { return null; } const start = this._start.isAfter(other._start) ? this._start : other._start; const thisEnd = (_a = this._end) !== null && _a !== void 0 ? _a : this.last(); const otherEnd = (_b = other._end) !== null && _b !== void 0 ? _b : other.last(); if (!thisEnd || !otherEnd) { return new ChronosPeriod(start, undefined, this._interval); } const end = thisEnd.isBefore(otherEnd) ? thisEnd : otherEnd; return new ChronosPeriod(start, end, this._interval); } /** * Get the union of two periods */ union(other) { var _a, _b; if (!this.overlaps(other) && !this._adjacentTo(other)) { return null; } const start = this._start.isBefore(other._start) ? this._start : other._start; const thisEnd = (_a = this._end) !== null && _a !== void 0 ? _a : this.last(); const otherEnd = (_b = other._end) !== null && _b !== void 0 ? _b : other.last(); if (!thisEnd || !otherEnd) { return new ChronosPeriod(start, undefined, this._interval); } const end = thisEnd.isAfter(otherEnd) ? thisEnd : otherEnd; return new ChronosPeriod(start, end, this._interval); } /** * Get the difference between two periods * Returns the parts of this period that don't overlap with the other period */ diff(other) { var _a, _b; if (!this.overlaps(other)) { return [this.clone()]; } const results = []; const thisEnd = (_a = this._end) !== null && _a !== void 0 ? _a : this.last(); const otherEnd = (_b = other._end) !== null && _b !== void 0 ? _b : other.last(); // Before the other period starts - create gap from this start to other start if (this._start.isBefore(other._start)) { results.push(new ChronosPeriod(this._start, other._start, this._interval)); } // After the other period ends - create gap from other end to this end if (otherEnd && thisEnd && thisEnd.isAfter(otherEnd)) { results.push(new ChronosPeriod(otherEnd, thisEnd, this._interval)); } return results; } /** * Check if this period is adjacent to another */ _adjacentTo(other) { var _a, _b; const thisEnd = (_a = this._end) !== null && _a !== void 0 ? _a : this.last(); const otherEnd = (_b = other._end) !== null && _b !== void 0 ? _b : other.last(); if (!thisEnd || !otherEnd) { return false; } return (thisEnd.add(this._interval.toDuration()).isSame(other._start) || otherEnd.add(other._interval.toDuration()).isSame(this._start)); } // ============================================================================ // Duration // ============================================================================ /** * Get the duration of the period */ duration() { if (this._end === null) { throw new Error('Cannot get duration of unbounded period'); } return interval_1.ChronosInterval.between(this._start.toDate(), this._end.toDate()); } /** * Get the number of days in the period */ days() { if (this._end === null) { throw new Error('Cannot get days of unbounded period'); } return Math.abs(this._start.diff(this._end, 'day')); } /** * Get the number of weeks in the period */ weeks() { if (this._end === null) { throw new Error('Cannot get weeks of unbounded period'); } return Math.abs(this._start.diff(this._end, 'week')); } /** * Get the number of months in the period */ monthCount() { if (this._end === null) { throw new Error('Cannot get months of unbounded period'); } return Math.abs(this._start.diff(this._end, 'month')); } /** * Get the number of years in the period */ yearCount() { if (this._end === null) { throw new Error('Cannot get years of unbounded period'); } return Math.abs(this._start.diff(this._end, 'year')); } // ============================================================================ // Splitting // ============================================================================ /** * Split the period into chunks */ split(count) { if (this._end === null) { throw new Error('Cannot split unbounded period'); } const totalDays = this.days(); const daysPerChunk = Math.ceil(totalDays / count); const chunks = []; let current = this._start.clone(); for (let i = 0; i < count && current.isSameOrBefore(this._end); i++) { const chunkEnd = current.addDays(daysPerChunk - 1); const end = chunkEnd.isAfter(this._end) ? this._end : chunkEnd; chunks.push(new ChronosPeriod(current, end, this._interval)); current = end.addDays(1); } return chunks; } /** * Split by a specific interval */ splitBy(interval) { if (this._end === null) { throw new Error('Cannot split unbounded period'); } const splitInterval = interval instanceof interval_1.ChronosInterval ? interval : interval_1.ChronosInterval.create(interval); // Validate that the interval is not zero if (splitInterval.isZero()) { throw new Error('Cannot split by zero interval'); } // Validate that the interval is positive if (splitInterval.isNegative()) { throw new Error('Cannot split by negative interval'); } const chunks = []; let current = this._start.clone(); while (current.isSameOrBefore(this._end)) { const chunkEnd = current .add(splitInterval.toDuration()) .subtract({ days: 1 }); const end = chunkEnd.isAfter(this._end) ? this._end : chunkEnd; chunks.push(new ChronosPeriod(current, end, this._interval)); current = current.add(splitInterval.toDuration()); } return chunks; } /** * Split the period by a specified number of days */ splitByDays(days) { return this.splitBy({ days }); } /** * Split the period by a specified number of weeks */ splitByWeeks(weeks) { return this.splitBy({ weeks }); } /** * Split the period by a specified number of months */ splitByMonths(months) { return this.splitBy({ months }); } /** * Split the period by a specified number of years */ splitByYears(years) { return this.splitBy({ years }); } /** * Skip specific dates from the period iteration * @param dates - Dates to exclude from iteration */ skip(dates) { const skipDates = dates.map((d) => chronos_1.Chronos.parse(d).format('YYYY-MM-DD')); return this.filter((date) => !skipDates.includes(date.format('YYYY-MM-DD'))); } // ============================================================================ // Formatting // ============================================================================ /** * Convert to ISO 8601 string */ toISO() { let iso = ''; if (this._recurrences !== null && this._recurrences !== Infinity) { iso += `R${this._recurrences}/`; } else if (this._recurrences === Infinity) { iso += 'R/'; } iso += this._start.toISOString().split('T')[0]; iso += '/'; if (this._end) { iso += this._end.toISOString().split('T')[0]; } else { iso += this._interval.toISO(); } return iso; } /** * Convert to string */ toString() { const start = this._start.format('YYYY-MM-DD'); const end = this._end ? this._end.format('YYYY-MM-DD') : '...'; return `${start} to ${end}`; } /** * Convert to human-readable string */ forHumans() { const count = this.count(); const start = this._start.format('MMMM D, YYYY'); const end = this._end ? this._end.format('MMMM D, YYYY') : 'indefinitely'; return `${count} dates from ${start} to ${end}`; } /** * Convert to JSON */ toJSON() { var _a, _b; return { start: this._start.toISOString(), end: (_b = (_a = this._end) === null || _a === void 0 ? void 0 : _a.toISOString()) !== null && _b !== void 0 ? _b : null, interval: this._interval.toISO(), recurrences: this._recurrences, options: this._options, }; } // ============================================================================ // Cloning and Locale // ============================================================================ /** * Clone this period */ clone() { var _a; const period = new ChronosPeriod(this._start, (_a = this._end) !== null && _a !== void 0 ? _a : undefined, this._interval, Object.assign({}, this._options)); period._recurrences = this._recurrences; period._filters = [...this._filters]; period._locale = this._locale; return period; } /** * Clone for modification (respects immutable option) */ _cloneForModification() { return this._options.immutable ? this.clone() : this; } /** * Set locale for this period */ locale(code) { const period = this._cloneForModification(); period._locale = (0, locales_1.getLocale)(code); return period; } // ============================================================================ // Static Helpers // ============================================================================ /** * Create a period for a specific month */ static month(year, month) { const start = chronos_1.Chronos.create(year, month, 1); const end = start.endOf('month'); return new ChronosPeriod(start, end, { days: 1 }); } /** * Create a period for a specific year */ static year(year) { const start = chronos_1.Chronos.create(year, 1, 1); const end = chronos_1.Chronos.create(year, 12, 31); return new ChronosPeriod(start, end, { days: 1 }); } /** * Create a period for a specific quarter */ static quarter(year, quarter) { const startMonth = (quarter - 1) * 3 + 1; const start = chronos_1.Chronos.create(year, startMonth, 1); const end = start.endOf('quarter'); return new ChronosPeriod(start, end, { days: 1 }); } /** * Create a period between two dates as weekdays only */ static weekdaysBetween(start, end) { return ChronosPeriod.create(start, end).weekdays(); } /** * Create a period with business days only (weekdays, can add holidays filter) */ static businessDays(start, end, holidays) { let period = ChronosPeriod.create(start, end).weekdays(); if (holidays && holidays.length > 0) { const holidayDates = holidays.map((h) => chronos_1.Chronos.parse(h).format('YYYY-MM-DD')); period = period.filter((date) => !holidayDates.includes(date.format('YYYY-MM-DD'))); } return period; } } exports.ChronosPeriod = ChronosPeriod;