@georgevie/period-sequence
Version:
High-performance TypeScript library for time period manipulation with immutable design and enterprise-grade performance
452 lines • 17.8 kB
JavaScript
"use strict";
/**
* High-performance Period class for date period handling
* Immutable value object representing time spans at day-level precision
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.Period = void 0;
const types_1 = require("./types");
const FastBounds_1 = require("./FastBounds");
class Period {
constructor(start, end, bounds = types_1.Bounds.IncludeStartExcludeEnd) {
// Normalize all inputs to midnight UTC
const startTime = this._normalizeDateToMidnightUTC(start);
const endTime = this._normalizeDateToMidnightUTC(end);
if (startTime >= endTime) {
throw new Error('Start date must be before end date');
}
this._startTime = startTime;
this._endTime = endTime;
this._bounds = bounds;
}
/**
* Normalize any date input to midnight UTC for consistent day-level operations
*/
_normalizeDateToMidnightUTC(input) {
let date;
let timestamp;
if (typeof input === 'string') {
date = new Date(input);
timestamp = date.getTime();
}
else if (typeof input === 'number') {
timestamp = input;
// Fast path: if already a midnight UTC timestamp, return directly
if (timestamp % 86400000 === 0) {
return timestamp;
}
date = new Date(timestamp);
}
else {
timestamp = input.getTime();
// Fast path: if already at midnight UTC, return directly
if (timestamp % 86400000 === 0) {
return timestamp;
}
date = input;
}
// Extract date components and create midnight UTC
const normalized = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
// Development warning for sub-day precision inputs (skip during tests for performance)
if (process.env.NODE_ENV === 'development' && typeof jest === 'undefined' && timestamp !== normalized) {
console.warn(`Period: Time component normalized to midnight UTC. Input: ${date.toISOString()} -> Output: ${new Date(normalized).toISOString()}`);
}
return normalized;
}
/**
* Get start date (creates new Date object only when needed)
*/
get start() {
return new Date(this._startTime);
}
/**
* Get end date (creates new Date object only when needed)
*/
get end() {
return new Date(this._endTime);
}
/**
* Get bounds type
*/
get bounds() {
return this._bounds;
}
/**
* Get start timestamp (high performance accessor)
*/
get startTime() {
return this._startTime;
}
/**
* Get end timestamp (high performance accessor)
*/
get endTime() {
return this._endTime;
}
/**
* Get duration in days (optimized for date-only operations)
* Much faster than getDuration().days for simple day calculations
*/
get durationInDays() {
return (this._endTime - this._startTime) / 86400000;
}
/**
* Calculate duration with day-level precision
*/
getDuration() {
const milliseconds = this._endTime - this._startTime;
const days = Math.floor(milliseconds / 86400000);
const hours = days * 24;
const minutes = hours * 60;
const seconds = minutes * 60;
return {
milliseconds,
seconds,
minutes,
hours,
days
};
}
/**
* Check if this period overlaps with another period
* Periods overlap if they share any common date, with boundary rules applied
* @param other - The period to check for overlap
* @returns True if periods overlap, false otherwise
*/
overlaps(other) {
// Fast path: check if periods are completely separate (no touching)
if (this._endTime < other._startTime || other._endTime < this._startTime) {
return false;
}
// Check if periods are adjacent (consecutive days)
if (this._endTime === other._startTime) {
// For date-only operations, adjacent periods can overlap based on bounds
const thisEndInclusive = FastBounds_1.FastBounds.isEndInclusive(this._bounds);
const otherStartInclusive = FastBounds_1.FastBounds.isStartInclusive(other._bounds);
return thisEndInclusive && otherStartInclusive;
}
if (other._endTime === this._startTime) {
const otherEndInclusive = FastBounds_1.FastBounds.isEndInclusive(other._bounds);
const thisStartInclusive = FastBounds_1.FastBounds.isStartInclusive(this._bounds);
return otherEndInclusive && thisStartInclusive;
}
// If they're not just touching, they must overlap
return true;
}
/**
* Check if this period contains another period
* Optimized for performance with inlined bounds awareness
*/
contains(other) {
// Inline bounds checking for performance
const thisStartValid = (this._bounds === types_1.Bounds.IncludeStartExcludeEnd || this._bounds === types_1.Bounds.IncludeAll)
? this._startTime <= other._startTime
: this._startTime < other._startTime;
const thisEndValid = (this._bounds === types_1.Bounds.ExcludeStartIncludeEnd || this._bounds === types_1.Bounds.IncludeAll)
? this._endTime >= other._endTime
: this._endTime > other._endTime;
return thisStartValid && thisEndValid;
}
/**
* Check if this period contains a specific date
* High-performance timestamp comparison with inlined bounds checking
*/
containsDate(date) {
const timestamp = typeof date === 'number' ? date : date.getTime();
// Inline bounds checking for maximum performance
const startValid = (this._bounds === types_1.Bounds.IncludeStartExcludeEnd || this._bounds === types_1.Bounds.IncludeAll)
? this._startTime <= timestamp
: this._startTime < timestamp;
const endValid = (this._bounds === types_1.Bounds.ExcludeStartIncludeEnd || this._bounds === types_1.Bounds.IncludeAll)
? this._endTime >= timestamp
: this._endTime > timestamp;
return startValid && endValid;
}
/**
* Check if periods touch (adjacent with no gap) - date-only version
* For date-only periods, this means consecutive days
*/
touches(other) {
return (this._endTime === other._startTime) || (other._endTime === this._startTime);
}
/**
* Check if periods abut (touch at exactly one point)
* Optimized with early returns
*/
abuts(other) {
// Fast path: if they don't touch, they can't abut
if (this._endTime !== other._startTime && other._endTime !== this._startTime) {
return false;
}
// Check if they touch but don't overlap
return !this.overlaps(other);
}
/**
* Fast equality check using timestamps and bounds
* Optimized with early returns
*/
equals(other) {
return this._startTime === other._startTime &&
this._endTime === other._endTime &&
this._bounds === other._bounds;
}
/**
* Create new period with different start date
* Optimized constructor call
*/
startingOn(start) {
return new Period(start, this._endTime, this._bounds);
}
/**
* Create new period with different end date
* Optimized constructor call
*/
endingOn(end) {
return new Period(this._startTime, end, this._bounds);
}
/**
* Create new period with different bounds
* Optimized constructor call
*/
withBounds(bounds) {
return new Period(this._startTime, this._endTime, bounds);
}
/**
* Internal method for object pooling - reset period with new values
* @internal
*/
_reset(start, end, bounds) {
const startTime = this._normalizeDateToMidnightUTC(start);
const endTime = this._normalizeDateToMidnightUTC(end);
if (startTime >= endTime) {
throw new Error('Start date must be before end date');
}
this._startTime = startTime;
this._endTime = endTime;
this._bounds = bounds;
// Clear any cached values
this._clearCache();
}
/**
* Internal method for object pooling - clear cached values
* @internal
*/
_clearCache() {
// Currently no cached values in Period, but method exists for future use
// This could clear cached duration, formatted strings, etc.
}
/**
* Create new period with specific duration from start
* Optimized with direct timestamp calculation
*/
withDuration(duration) {
const endTime = this._startTime + duration.milliseconds;
return new Period(this._startTime, endTime, this._bounds);
}
/**
* Move period by shifting both start and end by duration
* Optimized with direct timestamp operations
*/
move(duration) {
const startTime = this._startTime + duration.milliseconds;
const endTime = this._endTime + duration.milliseconds;
return new Period(startTime, endTime, this._bounds);
}
/**
* Move period backward by duration
* Convenience method for negative movement
*/
moveBackward(duration) {
const startTime = this._startTime - duration.milliseconds;
const endTime = this._endTime - duration.milliseconds;
return new Period(startTime, endTime, this._bounds);
}
/**
* Expand period by duration in both directions
* Optimized with single calculation
*/
expand(duration) {
const halfDuration = duration.milliseconds * 0.5;
const startTime = this._startTime - halfDuration;
const endTime = this._endTime + halfDuration;
return new Period(startTime, endTime, this._bounds);
}
/**
* Check if period is entirely before another period
* High-performance comparison
*/
isBefore(other) {
return this._endTime <= other._startTime;
}
/**
* Check if period is entirely after another period
* High-performance comparison
*/
isAfter(other) {
return this._startTime >= other._endTime;
}
/**
* Get the gap between this period and another (if any)
* Returns null if periods overlap or touch
*/
gap(other) {
if (this.overlaps(other) || this.touches(other)) {
return null;
}
if (this.isBefore(other)) {
return new Period(this._endTime, other._startTime, this._bounds);
}
else if (this.isAfter(other)) {
return new Period(other._endTime, this._startTime, this._bounds);
}
return null;
}
/**
* Format period with bounds notation for date-only display
* All formats show dates only - no time components
*/
format(dateFormat = 'iso') {
const [startBracket, endBracket] = types_1.BoundsUtils.getBrackets(this._bounds);
let startStr, endStr;
// Smart formatting defaults to ISO for date-only operations
if (dateFormat === 'smart') {
dateFormat = 'iso';
}
switch (dateFormat) {
case 'iso':
startStr = new Date(this._startTime).toISOString().slice(0, 10); // Date only (YYYY-MM-DD)
endStr = new Date(this._endTime).toISOString().slice(0, 10);
break;
case 'short':
const shortOptions = {
month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC'
};
startStr = new Date(this._startTime).toLocaleDateString('en-US', shortOptions);
endStr = new Date(this._endTime).toLocaleDateString('en-US', shortOptions);
break;
case 'long':
const longOptions = {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC'
};
startStr = new Date(this._startTime).toLocaleDateString('en-US', longOptions);
endStr = new Date(this._endTime).toLocaleDateString('en-US', longOptions);
break;
default:
// Fallback to ISO for any unrecognized format
startStr = new Date(this._startTime).toISOString().slice(0, 10);
endStr = new Date(this._endTime).toISOString().slice(0, 10);
break;
}
return `${startBracket}${startStr}, ${endStr}${endBracket}`;
}
/**
* Create string representation with date-only formatting
* Always shows dates only in ISO format
*/
toString() {
return this.format('iso');
}
/**
* Format as date only (same as toString for date-only periods)
* Example: [2024-01-15, 2024-01-16)
*/
toDateString() {
return this.format('iso');
}
/**
* Calculate intersection with another period
* Returns the overlapping period, or null if no overlap
*/
intersection(other) {
if (!this.overlaps(other)) {
return null;
}
const startTime = Math.max(this._startTime, other._startTime);
const endTime = Math.min(this._endTime, other._endTime);
// Use the more restrictive bounds
const bounds = this._bounds;
return new Period(startTime, endTime, bounds);
}
/**
* Merge with another period if they touch or overlap (date-only optimized)
* For date-only operations, consecutive days can be merged based on bounds
* Returns combined period, or null if they can't be merged
*/
union(other) {
// Check if they overlap or are consecutive days that should merge
if (!this.overlaps(other) && !this.canMergeConsecutiveDays(other)) {
return null;
}
const startTime = Math.min(this._startTime, other._startTime);
const endTime = Math.max(this._endTime, other._endTime);
// Use this period's bounds
return new Period(startTime, endTime, this._bounds);
}
/**
* Check if two consecutive day periods can be merged based on their bounds
*/
canMergeConsecutiveDays(other) {
if (!this.touches(other)) {
return false;
}
// For consecutive days to merge, the touching boundaries must both be inclusive
// or we need inclusive bounds that create continuity
if (this._endTime === other._startTime) {
const thisEndInclusive = FastBounds_1.FastBounds.isEndInclusive(this._bounds);
const otherStartInclusive = FastBounds_1.FastBounds.isStartInclusive(other._bounds);
return thisEndInclusive || otherStartInclusive; // Either can create continuity
}
if (other._endTime === this._startTime) {
const otherEndInclusive = FastBounds_1.FastBounds.isEndInclusive(other._bounds);
const thisStartInclusive = FastBounds_1.FastBounds.isStartInclusive(this._bounds);
return otherEndInclusive || thisStartInclusive; // Either can create continuity
}
return false;
}
/**
* Format for human readability with date-only display
* Example: "Jan 15, 2024", "Jan 15 - 20, 2024" or "Jan 15, 2024 - Feb 2, 2024"
*/
toDisplayString() {
const startDate = new Date(this._startTime);
const endDate = new Date(this._endTime);
// For date-only periods, check if start and end are consecutive days (1-day period)
const daysDiff = Math.floor((this._endTime - this._startTime) / 86400000);
if (daysDiff === 1) {
// Single day period: "Jan 15, 2024"
return startDate.toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC'
});
}
else {
// Multi-day periods: format based on date ranges
const startMonth = startDate.getUTCMonth();
const endMonth = endDate.getUTCMonth();
const startYear = startDate.getUTCFullYear();
const endYear = endDate.getUTCFullYear();
if (startYear === endYear && startMonth === endMonth) {
// Same month: "Jan 15 - 20, 2024"
const monthStr = startDate.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' });
return `${monthStr} ${startDate.getUTCDate()} - ${endDate.getUTCDate()}, ${startYear}`;
}
else if (startYear === endYear) {
// Same year: "Jan 15 - Feb 20, 2024"
const startStr = startDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
const endStr = endDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
return `${startStr} - ${endStr}, ${startYear}`;
}
else {
// Different years: "Dec 15, 2023 - Jan 20, 2024"
const startStr = startDate.toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC'
});
const endStr = endDate.toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC'
});
return `${startStr} - ${endStr}`;
}
}
}
}
exports.Period = Period;
//# sourceMappingURL=Period.js.map