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
JavaScript
"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;