From b424dc09ada622b2c5a85335ce755516eb1fb767 Mon Sep 17 00:00:00 2001 From: Derek Burgman Date: Wed, 6 Jul 2022 05:22:30 -0500 Subject: [PATCH] feat: added DateBlock --- packages/date/src/lib/date/date.block.spec.ts | 296 ++++++++++++++++++ packages/date/src/lib/date/date.block.ts | 241 ++++++++++++++ packages/date/src/lib/date/date.duration.ts | 5 + packages/date/src/lib/date/date.range.ts | 42 ++- packages/date/src/lib/date/date.ts | 32 +- packages/date/src/lib/date/index.ts | 1 + packages/date/src/lib/query/query.builder.ts | 4 +- packages/date/src/lib/rrule/date.rrule.ts | 4 +- packages/util/src/lib/array/array.index.ts | 8 +- packages/util/src/lib/array/array.number.ts | 16 +- packages/util/src/lib/date/date.ts | 1 + packages/util/src/lib/value/index.ts | 1 + packages/util/src/lib/value/indexed.ts | 66 ++++ 13 files changed, 694 insertions(+), 23 deletions(-) create mode 100644 packages/date/src/lib/date/date.block.spec.ts create mode 100644 packages/date/src/lib/date/date.block.ts create mode 100644 packages/util/src/lib/value/indexed.ts diff --git a/packages/date/src/lib/date/date.block.spec.ts b/packages/date/src/lib/date/date.block.spec.ts new file mode 100644 index 000000000..3ceb39c19 --- /dev/null +++ b/packages/date/src/lib/date/date.block.spec.ts @@ -0,0 +1,296 @@ +import { expectFail, itShouldFail } from '@dereekb/util/test'; +import { DateRange, DateRangeInput } from './date.range'; +import { addDays, addHours, addMinutes, setHours, setMinutes, startOfDay, endOfDay, addSeconds, addMilliseconds } from 'date-fns'; +import { DateBlock, dateBlockIndexRange, dateBlocksExpansionFactory, dateBlockTiming, DateBlockTiming, isValidDateBlockTiming } from './date.block'; +import { MS_IN_DAY, MINUTES_IN_DAY, range, RangeInput } from '@dereekb/util'; + +describe('dateBlockTiming', () => { + const startsAt = setMinutes(setHours(new Date(), 12), 0); // keep seconds to show rounding + const days = 5; + const minutes = 60; + + it('should allow a duration of 24 hours', () => { + const result = dateBlockTiming({ startsAt, duration: MINUTES_IN_DAY }, days); + expect(result).toBeDefined(); + expect(result.duration).toBe(MINUTES_IN_DAY); + }); + + itShouldFail('if the duration is greater than 24 hours.', () => { + expectFail(() => { + dateBlockTiming({ startsAt, duration: MINUTES_IN_DAY + 1 }, days); + }); + }); + + describe('range input', () => { + describe('number of days', () => { + it('should create a timing for a specific time that last 1 day', () => { + const days = 1; + const result = dateBlockTiming({ startsAt, duration: minutes }, days); + expect(result).toBeDefined(); + expect(result.start).toBeSameMinuteAs(startOfDay(startsAt)); + expect(result.startsAt).toBeSameMinuteAs(startsAt); + expect(result.duration).toBe(minutes); + expect(result.end).toBeSameMinuteAs(addMinutes(addDays(startsAt, days), minutes)); + }); + + it('should create a timing for a specific time that last 5 days', () => { + const result = dateBlockTiming({ startsAt, duration: minutes }, days); + expect(result).toBeDefined(); + expect(result.start).toBeSameMinuteAs(startOfDay(startsAt)); + expect(result.startsAt).toBeSameMinuteAs(startsAt); + expect(result.duration).toBe(minutes); + expect(result.end).toBeSameMinuteAs(addMinutes(addDays(startsAt, days), minutes)); + }); + }); + + describe('Range', () => { + it('should create a timing for a specific time that last 5 days using a Range', () => { + const start = addHours(startsAt, -6); + const dateRange: DateRange = { start: start, end: addDays(endOfDay(startsAt), days) }; + const result = dateBlockTiming({ startsAt, duration: minutes }, dateRange); + expect(result).toBeDefined(); + expect(result.start).toBeSameMinuteAs(start); + expect(result.startsAt).toBeSameMinuteAs(startsAt); + expect(result.duration).toBe(minutes); + expect(result.end).toBeSameMinuteAs(addMinutes(addDays(startsAt, days), minutes)); + }); + + it('should create a timing for a specific time that last 4 days using a Range', () => { + const start = addHours(startsAt, 6); // start is 6 hours after startAt + const dateRange: DateRange = { start: start, end: addDays(endOfDay(startsAt), days) }; + const result = dateBlockTiming({ startsAt, duration: minutes }, dateRange); + expect(result).toBeDefined(); + expect(result.start).toBeSameMinuteAs(start); + expect(result.startsAt).toBeSameMinuteAs(addDays(startsAt, 1)); + expect(result.duration).toBe(minutes); + expect(result.end).toBeSameMinuteAs(addMinutes(addDays(startsAt, days), minutes)); + }); + }); + + describe('DateRangeInput', () => { + it('should create a timing for a specific time that last 5 days using a DateRangeDayDistanceInput', () => { + const dateRangeInput: DateRangeInput = { date: startOfDay(startsAt), distance: days }; + const result = dateBlockTiming({ startsAt, duration: minutes }, dateRangeInput); + expect(result).toBeDefined(); + expect(result.start).toBeSameMinuteAs(startOfDay(startsAt)); + expect(result.startsAt).toBeSameMinuteAs(startsAt); + expect(result.duration).toBe(minutes); + expect(result.end).toBeSameMinuteAs(addMinutes(addDays(startsAt, days), minutes)); + }); + }); + }); +}); + +describe('isValidDateBlockTiming', () => { + const startsAt = setMinutes(setHours(new Date(), 12), 0); // keep seconds to show rounding + const validTiming = dateBlockTiming({ startsAt: startOfDay(new Date()), duration: 60 }, 1); + + it('should return true if the startsAt time is equal to the start time.', () => { + const isValid = isValidDateBlockTiming(dateBlockTiming({ startsAt: startOfDay(new Date()), duration: 60 }, 1)); + expect(isValid).toBe(true); + }); + + it('should return false if the starts time has seconds.', () => { + const invalidTiming: DateBlockTiming = { ...validTiming, start: addSeconds(validTiming.start, 10) }; + const isValid = isValidDateBlockTiming(invalidTiming); + expect(isValid).toBe(false); + }); + + it('should return false if the startsAt time is before the start time.', () => { + const start = addHours(startOfDay(startsAt), 2); + const isValid = isValidDateBlockTiming({ startsAt: addMinutes(start, -10), start, end: endOfDay(start), duration: 10 }); + expect(isValid).toBe(false); + }); + + it('should return false if the startsAt time is more than 24 hours after the start time.', () => { + const invalidTiming: DateBlockTiming = { ...validTiming, startsAt: addMilliseconds(validTiming.start, MS_IN_DAY + 1) }; + const isValid = isValidDateBlockTiming(invalidTiming); + expect(isValid).toBe(false); + }); + + it('should return false if the end is not the expected end time.', () => { + const invalidTiming: DateBlockTiming = { ...validTiming, end: addMinutes(validTiming.end, 1), duration: validTiming.duration }; + const isValid = isValidDateBlockTiming(invalidTiming); + expect(isValid).toBe(false); + }); + + it('should return false if the duration time is greater than 24 hours.', () => { + const invalidTiming: DateBlockTiming = { ...validTiming, duration: MINUTES_IN_DAY + 1 }; + const isValid = isValidDateBlockTiming(invalidTiming); + expect(isValid).toBe(false); + }); +}); + +/** + * A DateBlock with a string value. + */ +interface DataDateBlock extends DateBlock { + value: string; +} + +describe('dateBlocksExpansionFactory()', () => { + describe('function', () => { + function makeBlocks(input: RangeInput) { + return range(input).map((i) => ({ i, value: `${i}` })); + } + + const startsAt = setMinutes(setHours(new Date(), 12), 0); // keep seconds to show rounding + const days = 5; + const duration = 60; + + const timing = dateBlockTiming({ startsAt, duration }, days); + const factory = dateBlocksExpansionFactory({ timing }); + const blocks = makeBlocks(days); + + it('should generate the timings for the input date blocks.', () => { + const result = factory(blocks); + expect(result.length).toBe(days); + }); + + it('should filter out block indexes that fall outside the range.', () => { + const offset = 3; + const expectedResultCount = days - offset; + const blocks = makeBlocks({ start: offset, end: 8 }); + + const result = factory(blocks); + expect(result.length).toBe(expectedResultCount); + + const indexes = result.map((x) => x.i); + expect(indexes).not.toContain(0); + expect(indexes).not.toContain(1); + expect(indexes).not.toContain(2); + expect(indexes).not.toContain(5); + expect(indexes).not.toContain(6); + expect(indexes).not.toContain(7); + expect(indexes).not.toContain(8); + }); + + describe('with rangeLimit', () => { + describe('rangeLimit=duration', () => { + const daysLimit = 3; + const factory = dateBlocksExpansionFactory({ timing, rangeLimit: daysLimit }); + + it('should limit the index range to the first 3 days', () => { + const offset = 1; + const expectedResultCount = daysLimit - offset; + const blocks = makeBlocks({ start: offset, end: 5 }); + + const result = factory(blocks); + expect(result.length).toBe(expectedResultCount); + + const indexes = result.map((x) => x.i); + expect(indexes).not.toContain(0); + expect(indexes).toContain(1); + expect(indexes).toContain(2); + }); + }); + + describe('rangeLimit=range', () => { + const factory = dateBlocksExpansionFactory({ timing, rangeLimit: { start: addDays(timing.start, 1), end: addDays(timing.end, -1) } }); + + it('should limit the index range to the first 3 days', () => { + const expectedResultCount = 3; + + const result = factory(blocks); + expect(result.length).toBe(expectedResultCount); + + const indexes = result.map((x) => x.i); + expect(indexes).not.toContain(0); + expect(indexes).toContain(1); + expect(indexes).toContain(2); + expect(indexes).toContain(3); + expect(indexes).not.toContain(4); + }); + }); + + describe('rangeLimit=DateRangeDayDistanceInput', () => { + const factory = dateBlocksExpansionFactory({ timing, rangeLimit: { date: addDays(timing.start, 1), distance: 3 } }); + + it('should limit the index range to the first 3 days', () => { + const expectedResultCount = 3; + + const result = factory(blocks); + expect(result.length).toBe(expectedResultCount); + + const indexes = result.map((x) => x.i); + expect(indexes).not.toContain(0); + expect(indexes).toContain(1); + expect(indexes).toContain(2); + expect(indexes).toContain(3); + expect(indexes).not.toContain(4); + }); + }); + + describe('with rangeLimit=false', () => { + const factory = dateBlocksExpansionFactory({ timing, rangeLimit: false }); + + it('should generate a span for all blocks', () => { + const offset = 3; + const end = 8; + const expectedResultCount = end - offset; + const blocks = makeBlocks({ start: offset, end }); + + const result = factory(blocks); + expect(result.length).toBe(expectedResultCount); + + const indexes = result.map((x) => x.i); + expect(indexes).not.toContain(0); + expect(indexes).not.toContain(1); + expect(indexes).not.toContain(2); + expect(indexes).toContain(3); + expect(indexes).toContain(4); + expect(indexes).toContain(5); + expect(indexes).toContain(6); + expect(indexes).toContain(7); + }); + }); + }); + }); +}); + +describe('dateBlockIndexRange', () => { + const days = 5; + const start = new Date(0); + const end = addDays(start, days); + + const timing: DateBlockTiming = { + start, + end, + startsAt: start, + duration: 60 + }; + + it('should generate the dateBlockIndexRange for a given date.', () => { + const result = dateBlockIndexRange(timing); + expect(result.minIndex).toBe(0); + expect(result.maxIndex).toBe(days); + }); + + describe('with limit', () => { + it('should generate the dateBlockIndexRange for one day in the future (1,5).', () => { + const limit = { + start: addHours(start, 24), + end: end + }; + + const result = dateBlockIndexRange(timing, limit); + expect(result.minIndex).toBe(1); + expect(result.maxIndex).toBe(days); + }); + + describe('fitToTimingRange=false', () => { + it('should generate the dateBlockIndexRange for the limit.', () => { + const daysPastEnd = 2; + + const limit = { + start: addHours(start, 24), + end: addDays(end, daysPastEnd) + }; + + const result = dateBlockIndexRange(timing, limit, false); + expect(result.minIndex).toBe(1); + expect(result.maxIndex).toBe(days + daysPastEnd); + }); + }); + }); +}); diff --git a/packages/date/src/lib/date/date.block.ts b/packages/date/src/lib/date/date.block.ts new file mode 100644 index 000000000..a2f177a5d --- /dev/null +++ b/packages/date/src/lib/date/date.block.ts @@ -0,0 +1,241 @@ +import { IndexRange, indexRangeCheckFunction, indexRangeCheckReaderFunction, MINUTES_IN_DAY, MS_IN_DAY, range } from '@dereekb/util'; +import { dateRange, DateRange, DateRangeDayDistanceInput, DateRangeType, isDateRange } from './date.range'; +import { DateDurationSpan } from './date.duration'; +import { differenceInDays, differenceInMilliseconds, isBefore, addDays, addMinutes, setSeconds } from 'date-fns'; +import { copyHoursAndMinutesFromDate } from './date'; + +/** + * Index from 0 of which day this block represents. + */ +export type DateBlockIndex = number; + +/** + * A duration-span block. + */ +export interface DateBlock { + i: DateBlockIndex; +} + +/** + * An array of DateBlock-like values. + */ +export type DateBlockArray = B[]; + +/** + * Reference to a DateBlockArray + */ +export type DateBlockArrayRef = { + blocks: DateBlockArray; +}; + +/** + * Is combination of DateRange and DateDurationSpan. The DateRange captures a range of days that a DateBlock takes up, and the DateDurationSpan + * captures the Dates at which the Job occurs at. + * + * NOTES: + * - start time should be the first second of the day (0 seconds and 0 minutes) for its given timezone. This lets us derive the proper offset. + * - The startsAt time should be greater than or equal to start + * - The startsAt time should be on the same date as start + * - The end time should equal the ending date/time of the final end duration. + */ +export interface DateBlockTiming extends DateRange, DateDurationSpan {} + +/** + * The DateRange input for dateBlockTiming() + */ +export type DateBlockTimingRangeInput = DateRangeDayDistanceInput | DateRange | number; + +/** + * Creates a valid DateBlock timing from the DateDurationSpan and range input. + * + * The duration is first considered, then the date range is applied to it. + * + * If a number is passed as the input range, then the duration's startsAt date will be used. + * The input range's date takes priority over the duration's startsAt date. + */ +export function dateBlockTiming(durationInput: DateDurationSpan, inputRange: DateBlockTimingRangeInput): DateBlockTiming { + const { duration } = durationInput; + + if (duration > MINUTES_IN_DAY) { + throw new Error('dateBlockTiming() duration cannot be longer than 24 hours.'); + } + + let { startsAt } = durationInput; + let numberOfBlockedDays: number; + + let inputDate: Date | undefined; + let range: DateRange; + + if (typeof inputRange === 'number') { + numberOfBlockedDays = inputRange; + range = dateRange({ type: DateRangeType.DAY, date: startsAt, distance: numberOfBlockedDays }); + } else if (isDateRange(inputRange)) { + range = inputRange; + inputDate = inputRange.start; + numberOfBlockedDays = differenceInDays(inputRange.end, inputRange.start); + } else { + inputDate = inputRange.date; + numberOfBlockedDays = inputRange.distance; + range = dateRange(inputRange, true); + } + + if (inputDate != null) { + // input date takes priority, so move the startsAt's date to be on the same date. + startsAt = copyHoursAndMinutesFromDate(range.start, startsAt, true); + + if (isBefore(startsAt, range.start)) { + startsAt = addDays(startsAt, 1); // starts 24 hours later + numberOfBlockedDays = numberOfBlockedDays - 1; // reduce number of applied days by 1 + } + } else { + startsAt = setSeconds(startsAt, 0); // clear seconds from startsAt + } + + const start = range.start; + + // calculate end to be the ending date/time of the final duration span + const lastStart = addDays(startsAt, numberOfBlockedDays); + const end: Date = addMinutes(lastStart, duration); + + return { + start, + end, + startsAt, + duration + }; +} + +/** + * + * @param timing + * @returns + */ +export function isValidDateBlockTiming(timing: DateBlockTiming): boolean { + const { start, end, startsAt, duration } = timing; + const msDifference = differenceInMilliseconds(startsAt, start); + const hasSeconds = start.getSeconds() !== 0; + + let isValid: boolean = false; + + if ( + duration <= MINUTES_IN_DAY && + !hasSeconds && // start cannot have seconds + msDifference >= 0 && // startsAt is after secondsDifference + msDifference < MS_IN_DAY // startsAt is not more than 24 hours later + ) { + const expectedFinalStartTime = addMinutes(end, -duration); + const difference = differenceInMilliseconds(startsAt, expectedFinalStartTime) % MS_IN_DAY; + isValid = difference === 0; + } + + return isValid; +} + +/** + * Reference to a DateBlockTiming + */ +export type DateBlockTimingRef = { + timing: DateBlockTiming; +}; + +/** + * An object that implements DateBlockTiming and DateBlockArrayRef + */ +export interface DateBlockCollection extends DateBlockTiming, DateBlockArrayRef {} + +/** + * An expanded DateBlock that implements DateDurationSpan and contains the DateBlock values. + */ +export type DateBlockDurationSpan = DateDurationSpan & B; + +export function expandDateBlockCollection(collection: DateBlockCollection): DateBlockDurationSpan[] { + return expandDateBlocks(collection.blocks, collection); +} + +export function expandDateBlocks(blocks: DateBlock[], timing: DateBlockTiming): DateBlockDurationSpan[] { + return undefined as any; +} + +export type DateBlocksExpansionFactoryInput = DateBlockArrayRef | DateBlockArray; + +/** + * Used to convert the input DateBlocksExpansionFactoryInput into an array of DateBlockDurationSpan values + */ +export type DateBlocksExpansionFactory = (input: DateBlocksExpansionFactoryInput) => DateBlockDurationSpan[]; + +export interface DateBlocksExpansionFactoryConfig { + /** + * Timing to use in the configuration. + */ + timing: DateBlockTiming; + /** + * Range to limit duration span output to. + * + * If not provided, uses the input timing's range. + * If false, the timing's range is ignored too, and only the DateBlockIndex values are considered. + */ + rangeLimit?: DateBlockTimingRangeInput | false; +} + +/** + * Creates a DateBlocksExpansionFactory + * + * @param config + * @returns + */ +export function dateBlocksExpansionFactory(config: DateBlocksExpansionFactoryConfig): DateBlocksExpansionFactory { + const { timing, rangeLimit } = config; + const { startsAt: baseStart, duration } = timing; + const indexRange = rangeLimit !== false ? dateBlockIndexRange(timing, rangeLimit) : { minIndex: Number.MIN_SAFE_INTEGER, maxIndex: Number.MAX_SAFE_INTEGER }; + const isInRange = indexRangeCheckFunction(indexRange); + + return (input: DateBlocksExpansionFactoryInput) => { + const blocks = Array.isArray(input) ? input : input.blocks; + let spans: DateBlockDurationSpan[] = []; + + blocks.forEach((x) => { + if (isInRange(x.i)) { + const startsAt = addDays(baseStart, x.i); + const durationSpan: DateBlockDurationSpan = { + ...x, + startsAt, + duration + }; + spans.push(durationSpan); + } + }); + + return spans; + }; +} + +/** + * Generates a DateBlockIndexRange based on the input timing. + * + * An arbitrary limit can also be applied. + * + * @param timing + * @param limit + * @param fitToTimingRange + */ +export function dateBlockIndexRange(timing: DateBlockTiming, limit?: DateBlockTimingRangeInput, fitToTimingRange = true): IndexRange { + const { start: zeroDate, end: endDate } = timing; + let minIndex = 0; + let maxIndex = differenceInDays(endDate, zeroDate); + + if (limit) { + const { start, end } = dateBlockTiming(timing, limit); + const limitMin = differenceInDays(start, zeroDate); + const limitMax = differenceInDays(end, zeroDate); + + if (fitToTimingRange) { + minIndex = Math.min(limitMin, maxIndex); + maxIndex = Math.min(limitMax, maxIndex); + } else { + minIndex = limitMin; + maxIndex = limitMax; + } + } + + return { minIndex, maxIndex }; +} diff --git a/packages/date/src/lib/date/date.duration.ts b/packages/date/src/lib/date/date.duration.ts index 66a808fd4..77f17416b 100644 --- a/packages/date/src/lib/date/date.duration.ts +++ b/packages/date/src/lib/date/date.duration.ts @@ -3,6 +3,11 @@ import { Expose, Type } from 'class-transformer'; import { addMinutes } from 'date-fns'; import { DateRange, dateRangeState, DateRangeState } from './date.range'; +export interface DateDurationSpan { + startsAt: Date; + duration: Minutes; +} + export class DateDurationSpan { @Expose() @Type(() => Date) diff --git a/packages/date/src/lib/date/date.range.ts b/packages/date/src/lib/date/date.range.ts index d865116c7..408e7e70d 100644 --- a/packages/date/src/lib/date/date.range.ts +++ b/packages/date/src/lib/date/date.range.ts @@ -1,6 +1,6 @@ import { Type } from 'class-transformer'; import { IsEnum, IsOptional, IsDate, IsNumber } from 'class-validator'; -import { addDays, addHours, endOfDay, endOfMonth, endOfWeek, isPast, startOfDay, startOfMinute, startOfMonth, startOfWeek } from 'date-fns'; +import { addDays, addHours, endOfDay, endOfMonth, endOfWeek, isDate, isPast, startOfDay, startOfMinute, startOfMonth, startOfWeek } from 'date-fns'; /** * Represents a start and end date. @@ -10,6 +10,16 @@ export interface DateRange { end: Date; } +/** + * Returns true if the input is a DateRange. + * + * @param input + * @returns + */ +export function isDateRange(input: unknown): input is DateRange { + return typeof input === 'object' && isDate((input as DateRange).start) && isDate((input as DateRange).end); +} + export enum DateRangeType { /** * Includes only the target day. @@ -113,6 +123,28 @@ export class DateRangeParams { } } +export interface DateRangeTypedInput { + type: DateRangeType; + date?: Date; + distance?: number; +} + +/** + * dateRange() input that infers duration to be a number of days, starting from the input date if applicable. + */ +export interface DateRangeDayDistanceInput { + date?: Date; + distance: number; +} + +export interface DateRangeDistanceInput extends DateRangeDayDistanceInput { + type?: DateRangeType; +} + +export type DateRangeInput = (DateRangeTypedInput | DateRangeDistanceInput) & { + roundToMinute?: boolean; +}; + /** * Creates a DateRange from the input DateRangeParams * @@ -120,7 +152,7 @@ export class DateRangeParams { * @param roundToMinute * @returns */ -export function makeDateRange({ type, date = new Date(), distance }: DateRangeParams, roundToMinute = false): DateRange { +export function dateRange({ type = DateRangeType.DAY, date = new Date(), distance, roundToMinute: inputRoundToMinute = false }: DateRangeInput, roundToMinute = inputRoundToMinute): DateRange { let start: Date; let end: Date; @@ -222,3 +254,9 @@ export function dateRangeState({ start, end }: DateRange): DateRangeState { return DateRangeState.FUTURE; } } + +// MARK: Compat +/** + * @deprecated use dateRange() instead. + */ +export const makeDateRange = dateRange; diff --git a/packages/date/src/lib/date/date.ts b/packages/date/src/lib/date/date.ts index 98c018457..257c06865 100644 --- a/packages/date/src/lib/date/date.ts +++ b/packages/date/src/lib/date/date.ts @@ -115,23 +115,33 @@ export function takeNextUpcomingTime(date: Date, removeSeconds?: boolean): Date } /** - * Creates a new date and copies the hours/minutes from the previous date. + * Creates a new date and copies the hours/minutes from the previous date and applies them to a date for today. */ -export function copyHoursAndMinutesFromDateToToday(date: Date, removeSeconds?: boolean): Date { - return copyHoursAndMinutesToToday({ - hours: date.getHours(), - minutes: date.getMinutes(), - removeSeconds - }); +export function copyHoursAndMinutesFromDateToToday(fromDate: Date, removeSeconds?: boolean): Date { + return copyHoursAndMinutesFromDate(new Date(), fromDate, removeSeconds); +} + +/** + * Creates a new date and copies the hours/minutes from the input date to the target date. + */ +export function copyHoursAndMinutesFromDate(target: Date, fromDate: Date, removeSeconds?: boolean): Date { + return copyHoursAndMinutesToDate( + { + hours: fromDate.getHours(), + minutes: fromDate.getMinutes(), + removeSeconds + }, + target + ); } /** - * Creates a new date and copies the hours/minutes from the input. + * Creates a new date and copies the hours/minutes from the input onto the target date, if provided. Defaults to now/today otherwise. * * Also rounds the seconds and milliseconds. */ -export function copyHoursAndMinutesToToday({ hours, minutes, removeSeconds = true }: { hours: number; minutes?: number; removeSeconds?: boolean }): Date { - return setDateValues(new Date(), { +export function copyHoursAndMinutesToDate({ hours, minutes, removeSeconds = true }: { hours: number; minutes?: number; removeSeconds?: boolean }, target?: Maybe): Date { + return setDateValues(target ?? new Date(), { hours, ...(minutes != null ? { @@ -148,6 +158,8 @@ export function copyHoursAndMinutesToToday({ hours, minutes, removeSeconds = tru }); } +export const copyHoursAndMinutesToToday = copyHoursAndMinutesToDate; + /** * Removes the seconds and milliseconds from the input date. */ diff --git a/packages/date/src/lib/date/index.ts b/packages/date/src/lib/date/index.ts index bf94ed6f3..d5dd20dbc 100644 --- a/packages/date/src/lib/date/index.ts +++ b/packages/date/src/lib/date/index.ts @@ -1,3 +1,4 @@ +export * from './date.block'; export * from './date.calendar'; export * from './date.duration'; export * from './date.format'; diff --git a/packages/date/src/lib/query/query.builder.ts b/packages/date/src/lib/query/query.builder.ts index 4bd6f8deb..a8b2bf057 100644 --- a/packages/date/src/lib/query/query.builder.ts +++ b/packages/date/src/lib/query/query.builder.ts @@ -1,7 +1,7 @@ import { Maybe, TimezoneString } from '@dereekb/util'; import { addSeconds } from 'date-fns'; import { utcToZonedTime } from 'date-fns-tz'; -import { DateRangeType, makeDateRange } from '../date/date.range'; +import { DateRangeType, dateRange } from '../date/date.range'; import { DateDayTimezoneHintFilter, DateItemOccuringFilter, DateItemQueryStartsEndsFilter, DateItemRangeFilter } from './query.filter'; export interface DaysAndTimeFilter { @@ -84,7 +84,7 @@ export function makeDateQueryForDateItemRangeFilter(find: DateItemRangeFilter): // Apply the timezone to the date range if provided. const range = find.timezone ? { ...find.range, date: utcToZonedTime(find.range.date, find.timezone) } : find.range; - const dateRange = makeDateRange(range); + const dateRange = dateRange(range); switch (range.type) { case DateRangeType.DAY: diff --git a/packages/date/src/lib/rrule/date.rrule.ts b/packages/date/src/lib/rrule/date.rrule.ts index 40f31bd85..a16473437 100644 --- a/packages/date/src/lib/rrule/date.rrule.ts +++ b/packages/date/src/lib/rrule/date.rrule.ts @@ -1,6 +1,6 @@ import { Maybe, TimezoneString } from '@dereekb/util'; import { RRule, Options } from 'rrule'; -import { CalendarDate, DateSet, DateRange, DateRangeParams, makeDateRange, maxFutureDate, durationSpanToDateRange } from '../date'; +import { CalendarDate, DateSet, DateRange, DateRangeParams, dateRange, maxFutureDate, durationSpanToDateRange } from '../date'; import { BaseDateAsUTC, DateTimezoneUtcNormalInstance } from '../date/date.timezone'; import { DateRRule } from './date.rrule.extension'; import { DateRRuleParseUtility, RRuleLines, RRuleStringLineSet, RRuleStringSetSeparation } from './date.rrule.parse'; @@ -178,7 +178,7 @@ export class DateRRuleInstance { let between: Maybe; if (options.range || options.rangeParams) { - between = options.range ?? makeDateRange(options.rangeParams as DateRangeParams); + between = options.range ?? dateRange(options.rangeParams as DateRangeParams); between.start = this.normalInstance.baseDateToTargetDate(between.start); between.end = this.normalInstance.baseDateToTargetDate(between.end); } diff --git a/packages/util/src/lib/array/array.index.ts b/packages/util/src/lib/array/array.index.ts index e67c1df8e..39f3d7b0f 100644 --- a/packages/util/src/lib/array/array.index.ts +++ b/packages/util/src/lib/array/array.index.ts @@ -1,3 +1,4 @@ +import { IndexNumber, IndexRef } from '../value/indexed'; import { Maybe } from '../value/maybe.type'; /** @@ -5,12 +6,11 @@ import { Maybe } from '../value/maybe.type'; * * This is useful for cases where you need the items in their index in the array. */ -export type IndexSet = number[]; +export type IndexSet = IndexNumber[]; export type IndexSetPairSet = IndexSetPair[]; -export interface IndexSetPair { - i: number; +export interface IndexSetPair extends IndexRef { item: Maybe; } @@ -22,7 +22,7 @@ export interface IndexSetPair { * @returns */ export function findToIndexSet(input: T[], filter: (value: T) => boolean): IndexSet { - const filterIndexes: number[] = []; + const filterIndexes: IndexNumber[] = []; input.forEach((x, i) => { if (filter(x)) { diff --git a/packages/util/src/lib/array/array.number.ts b/packages/util/src/lib/array/array.number.ts index 0e897c8b7..f97ec88da 100644 --- a/packages/util/src/lib/array/array.number.ts +++ b/packages/util/src/lib/array/array.number.ts @@ -37,6 +37,11 @@ export function reduceNumbersFn(reduceFn: (a: number, b: numbe return (array: number[]) => (array.length ? rFn(array) : emptyArrayValue); } +/** + * Input for range() + */ +export type RangeInput = number | { start?: number; end: number }; + /** * Generates an array containing the range of numbers specified. * @@ -45,15 +50,20 @@ export function reduceNumbersFn(reduceFn: (a: number, b: numbe * @param param0 * @returns */ -export function range(input: number | { start?: number; end: number }): number[] { +export function range(input: RangeInput, inputEnd?: number): number[] { const range = []; let start: number; let end: number; if (typeof input === 'number') { - start = 0; - end = input; + if (typeof inputEnd === 'number') { + start = input; + end = inputEnd; + } else { + start = 0; + end = input; + } } else { start = input.start ?? 0; end = input.end; diff --git a/packages/util/src/lib/date/date.ts b/packages/util/src/lib/date/date.ts index 4089a54d8..a030838a5 100644 --- a/packages/util/src/lib/date/date.ts +++ b/packages/util/src/lib/date/date.ts @@ -65,3 +65,4 @@ export type Days = number; export const MINUTES_IN_DAY = 1440; export const MS_IN_MINUTE = 1000 * 60; export const MS_IN_HOUR = MS_IN_MINUTE * 60; +export const MS_IN_DAY = MS_IN_HOUR * 24; diff --git a/packages/util/src/lib/value/index.ts b/packages/util/src/lib/value/index.ts index f455a68b3..ac8ec3f1c 100644 --- a/packages/util/src/lib/value/index.ts +++ b/packages/util/src/lib/value/index.ts @@ -1,6 +1,7 @@ export * from './build'; export * from './decision'; export * from './equal'; +export * from './indexed'; export * from './map'; export * from './maybe.type'; export * from './maybe'; diff --git a/packages/util/src/lib/value/indexed.ts b/packages/util/src/lib/value/indexed.ts new file mode 100644 index 000000000..31718e5c9 --- /dev/null +++ b/packages/util/src/lib/value/indexed.ts @@ -0,0 +1,66 @@ +/** + * A number that denotes which index an item is at. + */ +export type IndexNumber = number; + +/** + * Item that references an IndexNumber. + */ +export interface IndexRef { + /** + * Item's index number + */ + i: IndexNumber; +} + +/** + * Returns an item's IndexNumber. + */ +export type ReadIndexFunction = (value: T) => IndexNumber; + +// MARK: IndexRange +/** + * A min and max value that denote the maximum edges of a range of index values. + */ +export interface IndexRange { + /** + * Minimum index to consider, inclusive. + */ + minIndex: IndexNumber; + /** + * Maximum index allowed, exclusive. + */ + maxIndex: IndexNumber; +} + +/** + * Checks whether or not the input number is in the range. + */ +export type IndexRefRangeCheckFunction = (value: T) => boolean; + +/** + * Creates an IndexRefRangeCheckFunction + * + * @param range + */ +export function indexRangeCheckReaderFunction(range: IndexRange): IndexRefRangeCheckFunction; +export function indexRangeCheckReaderFunction(range: IndexRange, read: ReadIndexFunction): IndexRefRangeCheckFunction; +export function indexRangeCheckReaderFunction(range: IndexRange, read: ReadIndexFunction = (x: T) => (x as unknown as IndexRef).i): IndexRefRangeCheckFunction { + const rangeCheck = indexRangeCheckFunction(range); + return (value: T) => rangeCheck(read(value)); +} + +/** + * Checks whether or not the input number is in the range. + */ +export type IndexRangeCheckFunction = (i: number) => boolean; + +/** + * Creates an IndexRangeCheckFunction + * + * @param range + */ +export function indexRangeCheckFunction(range: IndexRange): IndexRangeCheckFunction { + const { minIndex, maxIndex } = range; + return (i) => i >= minIndex && i < maxIndex; +}