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.

563 lines (562 loc) 19.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ChronosPeriodCollection = void 0; /** * ChronosPeriodCollection - Manage collections of ChronosPeriod * Inspired by spatie/period PHP library * @see https://github.com/spatie/period */ const period_1 = require("./period"); const chronos_1 = require("./chronos"); /** * ChronosPeriodCollection - A collection of periods with powerful operations * * Inspired by spatie/period, this class provides a rich API for working with * collections of time periods including overlap detection, gap analysis, * and set operations. * * @example * ```typescript * const collection = new ChronosPeriodCollection([ * ChronosPeriod.create('2024-01-01', '2024-01-15'), * ChronosPeriod.create('2024-01-10', '2024-01-25'), * ChronosPeriod.create('2024-02-01', '2024-02-15'), * ]); * * // Find overlapping periods * const overlapping = collection.overlapAll(); * * // Get gaps between periods * const gaps = collection.gaps(); * * // Get boundaries * const boundaries = collection.boundaries(); * ``` */ class ChronosPeriodCollection { // ============================================================================ // Constructor & Factory Methods // ============================================================================ constructor(periods = []) { this._periods = [...periods]; } /** * Create a new collection from periods */ static create(...periods) { return new ChronosPeriodCollection(periods); } /** * Create an empty collection */ static empty() { return new ChronosPeriodCollection(); } /** * Create a collection from an array of date pairs */ static fromDatePairs(pairs) { const periods = pairs.map(([start, end]) => period_1.ChronosPeriod.create(start, end)); return new ChronosPeriodCollection(periods); } // ============================================================================ // Basic Operations // ============================================================================ /** Add a period to the collection */ add(period) { this._periods.push(period); return this; } /** Add multiple periods */ addAll(periods) { this._periods.push(...periods); return this; } /** Get a shallow copy of periods */ toArray() { return [...this._periods]; } /** Get the number of periods in the collection */ get length() { return this._periods.length; } /** Check if collection is empty */ isEmpty() { return this._periods.length === 0; } /** Check if collection is not empty */ isNotEmpty() { return this._periods.length > 0; } /** Get a period at a specific index */ get(index) { return this._periods[index]; } /** Get the first period */ first() { return this._periods[0]; } /** Get the last period */ last() { return this._periods[this._periods.length - 1]; } /** Clear collection */ clear() { this._periods = []; return this; } // ============================================================================ // Iteration // ============================================================================ /** Iterator implementation */ *[Symbol.iterator]() { for (const period of this._periods) { yield period; } } /** ForEach iteration */ forEach(callback) { this._periods.forEach(callback); } /** Map periods to a new array */ map(callback) { return this._periods.map(callback); } /** Filter periods */ filter(predicate) { return new ChronosPeriodCollection(this._periods.filter(predicate)); } /** Reduce periods to a single value */ reduce(callback, initial) { return this._periods.reduce(callback, initial); } /** Find a period matching a predicate */ find(predicate) { return this._periods.find(predicate); } /** Check if any period matches a predicate */ some(predicate) { return this._periods.some(predicate); } /** Check if all periods match a predicate */ every(predicate) { return this._periods.every(predicate); } // ============================================================================ // Boundaries (spatie/period inspired) // ============================================================================ /** * Get the overall boundaries of the collection * Returns a period from the earliest start to the latest end */ boundaries() { var _a; if (this._periods.length === 0) return null; let earliestStart = null; let latestEnd = null; for (const period of this._periods) { if (!earliestStart || period.start.isBefore(earliestStart)) { earliestStart = period.start; } const end = (_a = period.end) !== null && _a !== void 0 ? _a : period.last(); if (end && (!latestEnd || end.isAfter(latestEnd))) { latestEnd = end; } } if (!earliestStart) return null; return period_1.ChronosPeriod.create(earliestStart, latestEnd !== null && latestEnd !== void 0 ? latestEnd : earliestStart); } /** * Get the earliest start date across all periods */ start() { if (this._periods.length === 0) return null; let earliest = null; for (const period of this._periods) { if (!earliest || period.start.isBefore(earliest)) { earliest = period.start; } } return earliest; } /** * Get the latest end date across all periods */ end() { var _a; if (this._periods.length === 0) return null; let latest = null; for (const period of this._periods) { const end = (_a = period.end) !== null && _a !== void 0 ? _a : period.last(); if (end && (!latest || end.isAfter(latest))) { latest = end; } } return latest; } // ============================================================================ // Overlap Operations (spatie/period inspired) // ============================================================================ /** Normalize and merge overlapping/adjacent periods */ normalize() { if (this._periods.length === 0) return []; // Sort by start const sorted = this._periods.slice().sort((a, b) => { const aStart = a.start.toDate().getTime(); const bStart = b.start.toDate().getTime(); return aStart - bStart; }); const merged = []; let current = sorted[0].clone(); for (let i = 1; i < sorted.length; i++) { const next = sorted[i]; const union = current.union(next); if (union) { current = union; } else { merged.push(current); current = next.clone(); } } merged.push(current); return merged; } /** Check if any period overlaps with the provided period */ overlaps(period) { return this._periods.some((p) => p.overlaps(period)); } /** * Check if any period in the collection overlaps with any other period * in the collection (internal overlaps) */ overlapAny() { for (let i = 0; i < this._periods.length; i++) { for (let j = i + 1; j < this._periods.length; j++) { if (this._periods[i].overlaps(this._periods[j])) { return true; } } } return false; } /** * Get all overlapping period segments across the collection * Returns periods where two or more periods in the collection overlap */ overlapAll() { if (this._periods.length < 2) { return ChronosPeriodCollection.empty(); } const overlaps = []; // Sort periods by start date const sorted = this._periods .slice() .sort((a, b) => a.start.toDate().getTime() - b.start.toDate().getTime()); // Find all pairwise intersections for (let i = 0; i < sorted.length; i++) { for (let j = i + 1; j < sorted.length; j++) { const intersection = sorted[i].intersect(sorted[j]); if (intersection) { // Check if this intersection is not already covered const isDuplicate = overlaps.some((existing) => { var _a, _b; return existing.start.isSame(intersection.start, 'day') && ((_a = existing.end) === null || _a === void 0 ? void 0 : _a.isSame((_b = intersection.end) !== null && _b !== void 0 ? _b : null, 'day')); }); if (!isDuplicate) { overlaps.push(intersection); } } } } return new ChronosPeriodCollection(overlaps); } /** Return intersections between collection and a given period */ intersect(period) { const intersections = []; for (const p of this._periods) { const inter = p.intersect(period); if (inter) intersections.push(inter); } return new ChronosPeriodCollection(intersections); } /** * Get intersection of all periods in the collection * Returns the period where ALL periods overlap (if any) */ intersectAll() { if (this._periods.length === 0) return null; if (this._periods.length === 1) return this._periods[0].clone(); let result = this._periods[0].clone(); for (let i = 1; i < this._periods.length; i++) { if (!result) return null; result = result.intersect(this._periods[i]); } return result; } // ============================================================================ // Union Operations // ============================================================================ /** Return the union (merged) of all periods in the collection */ union() { return new ChronosPeriodCollection(this.normalize()); } /** * Alias for normalize() - returns merged periods * @deprecated Use union() instead */ unionAll() { return this.normalize(); } /** Merge collection into a single union period if contiguous/overlapping */ mergeToSingle() { const merged = this.normalize(); if (merged.length === 0) return null; if (merged.length === 1) return merged[0]; // If there are multiple, they are not adjacent/overlapping, cannot merge into single return null; } // ============================================================================ // Gap Operations (spatie/period inspired) // ============================================================================ /** Return gaps between merged periods */ gaps() { var _a; const merged = this.normalize(); const gaps = []; for (let i = 0; i < merged.length - 1; i++) { const current = merged[i]; const next = merged[i + 1]; const end = (_a = current.end) !== null && _a !== void 0 ? _a : current.last(); const startNext = next.start; if (end && startNext) { const gapStart = end.add(current.interval.toDuration()); const gapEnd = startNext.subtract(next.interval.toDuration()); // Only create gap if there's actual space between periods if (gapStart.isSameOrBefore(gapEnd)) { gaps.push(period_1.ChronosPeriod.create(gapStart, gapEnd, current.interval)); } } } return new ChronosPeriodCollection(gaps); } /** * Check if there are any gaps between periods */ hasGaps() { return this.gaps().isNotEmpty(); } // ============================================================================ // Subtraction Operations (spatie/period inspired) // ============================================================================ /** * Subtract a period from all periods in the collection * Returns periods with the subtracted portion removed */ subtract(period) { const results = []; for (const p of this._periods) { const diffs = p.diff(period); results.push(...diffs); } return new ChronosPeriodCollection(results); } /** * Subtract multiple periods from the collection */ subtractAll(periods) { let result = new ChronosPeriodCollection([...this._periods]); for (const period of periods) { result = result.subtract(period); } return result; } // ============================================================================ // Touching/Adjacent Operations (spatie/period inspired) // ============================================================================ /** * Check if any period touches (is adjacent to) the given period * Two periods touch if one ends exactly where the other begins */ touchesWith(period) { return this._periods.some((p) => this._periodsTouch(p, period)); } /** * Check if two periods touch (are adjacent) */ _periodsTouch(a, b) { var _a, _b; const aEnd = (_a = a.end) !== null && _a !== void 0 ? _a : a.last(); const bEnd = (_b = b.end) !== null && _b !== void 0 ? _b : b.last(); if (!aEnd || !bEnd) return false; // a ends exactly where b starts if (aEnd.add(a.interval.toDuration()).isSame(b.start)) return true; // b ends exactly where a starts if (bEnd.add(b.interval.toDuration()).isSame(a.start)) return true; return false; } /** * Get all periods that touch the given period */ touchingPeriods(period) { return this.filter((p) => this._periodsTouch(p, period)); } // ============================================================================ // Contains Operations (spatie/period inspired) // ============================================================================ /** * Check if a date is contained in any period of the collection */ contains(date) { const target = chronos_1.Chronos.parse(date); return this._periods.some((p) => p.contains(target)); } /** * Check if a period is fully contained in any period of the collection */ containsPeriod(period) { return this._periods.some((p) => { var _a, _b; const pEnd = (_a = p.end) !== null && _a !== void 0 ? _a : p.last(); const periodEnd = (_b = period.end) !== null && _b !== void 0 ? _b : period.last(); if (!pEnd || !periodEnd) return false; return (p.start.isSameOrBefore(period.start) && pEnd.isSameOrAfter(periodEnd)); }); } // ============================================================================ // Equality Operations (spatie/period inspired) // ============================================================================ /** * Check if two collections are equal (same periods) */ equals(other) { var _a, _b; if (this._periods.length !== other._periods.length) { return false; } const thisSorted = this._sortedByStart(); const otherSorted = other._sortedByStart(); for (let i = 0; i < thisSorted.length; i++) { const thisEnd = (_a = thisSorted[i].end) !== null && _a !== void 0 ? _a : thisSorted[i].last(); const otherEnd = (_b = otherSorted[i].end) !== null && _b !== void 0 ? _b : otherSorted[i].last(); if (!thisSorted[i].start.isSame(otherSorted[i].start, 'day')) { return false; } if (thisEnd && otherEnd && !thisEnd.isSame(otherEnd, 'day')) { return false; } } return true; } /** * Get periods sorted by start date */ _sortedByStart() { return this._periods .slice() .sort((a, b) => a.start.toDate().getTime() - b.start.toDate().getTime()); } // ============================================================================ // Sorting & Reversing // ============================================================================ /** * Sort periods by start date (ascending) */ sortByStart() { return new ChronosPeriodCollection(this._sortedByStart()); } /** * Sort periods by end date (ascending) */ sortByEnd() { const sorted = this._periods.slice().sort((a, b) => { var _a, _b, _c, _d, _e, _f; const aEnd = (_c = (_b = ((_a = a.end) !== null && _a !== void 0 ? _a : a.last())) === null || _b === void 0 ? void 0 : _b.toDate().getTime()) !== null && _c !== void 0 ? _c : 0; const bEnd = (_f = (_e = ((_d = b.end) !== null && _d !== void 0 ? _d : b.last())) === null || _e === void 0 ? void 0 : _e.toDate().getTime()) !== null && _f !== void 0 ? _f : 0; return aEnd - bEnd; }); return new ChronosPeriodCollection(sorted); } /** * Sort periods by duration (ascending) */ sortByDuration() { const sorted = this._periods.slice().sort((a, b) => { try { return a.days() - b.days(); } catch (_a) { return 0; } }); return new ChronosPeriodCollection(sorted); } /** * Reverse the order of periods */ reverse() { return new ChronosPeriodCollection([...this._periods].reverse()); } // ============================================================================ // Conversion & Output // ============================================================================ /** * Convert to JSON */ toJSON() { return this._periods.map((p) => p.toJSON()); } /** * Convert to string */ toString() { if (this._periods.length === 0) return '(empty collection)'; return this._periods.map((p) => p.toString()).join(', '); } /** * Get total duration across all periods (in days) * Note: Overlapping portions may be counted multiple times */ totalDays() { return this._periods.reduce((sum, p) => { try { return sum + p.days(); } catch (_a) { return sum; } }, 0); } /** * Get total unique duration (merged periods, no double-counting) */ uniqueDays() { const merged = this.normalize(); return merged.reduce((sum, p) => { try { return sum + p.days(); } catch (_a) { return sum; } }, 0); } } exports.ChronosPeriodCollection = ChronosPeriodCollection;