168 lines
4.9 KiB
TypeScript
168 lines
4.9 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
|
|
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '../../../common/time';
|
|
import {
|
|
DateOnlyRange,
|
|
dateParts,
|
|
moscowDate,
|
|
utcDate,
|
|
} from '../investment-calendar';
|
|
|
|
const MONDAY_WEEKDAY_INDEX = 1;
|
|
const INCLUSIVE_DAY_OFFSET = 1;
|
|
const NEXT_DAY_OFFSET = 1;
|
|
const PREVIOUS_MONTH_LAST_DAY = 0;
|
|
const PREVIOUS_DAY_OFFSET = -1;
|
|
const PREVIOUS_YEAR_OFFSET = 1;
|
|
const DATE_COMPONENT_DIGITS = 2;
|
|
|
|
const DAILY_BUCKET_MAX_DAYS = 45;
|
|
const WEEKLY_BUCKET_MAX_DAYS = 180;
|
|
const DAYS_PER_WEEK = 7;
|
|
const ISO_MONTH_LENGTH = 7;
|
|
const ISO_YEAR_LENGTH = 4;
|
|
|
|
export type IncomeBucketGranularity = 'day' | 'month' | 'week';
|
|
|
|
export function planIncomePeriod(
|
|
from: string | undefined,
|
|
to: string | undefined,
|
|
openedDate?: Date,
|
|
): {
|
|
granularity: IncomeBucketGranularity;
|
|
previous: DateOnlyRange;
|
|
selected: DateOnlyRange;
|
|
yearAgo: DateOnlyRange | null;
|
|
} {
|
|
if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
|
|
const today = moscowDate(new Date());
|
|
const selected =
|
|
from && to ? { from, to } : { from: firstOfMonth(today), to: today };
|
|
assertRange(selected, today);
|
|
const previous = isFullMonth(selected) ?
|
|
previousMonth(selected.from) :
|
|
shiftDays(selected, -rangeDays(selected));
|
|
const candidate = yearAgo(selected);
|
|
const yearAgoRange =
|
|
candidate && (!openedDate || candidate.from >= moscowDate(openedDate)) ?
|
|
candidate :
|
|
null;
|
|
return {
|
|
selected,
|
|
previous,
|
|
yearAgo: yearAgoRange,
|
|
granularity:
|
|
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS ?
|
|
'day' :
|
|
rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS ?
|
|
'week' :
|
|
'month',
|
|
};
|
|
}
|
|
|
|
export function rangeBuckets(
|
|
range: DateOnlyRange,
|
|
granularity: IncomeBucketGranularity,
|
|
): DateOnlyRange[] {
|
|
if (granularity === 'day') {
|
|
return dates(range).map((date) => ({ from: date, to: date }));
|
|
}
|
|
const buckets: DateOnlyRange[] = [];
|
|
let cursor =
|
|
granularity === 'week' ?
|
|
addDays(
|
|
range.from,
|
|
-(
|
|
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) %
|
|
DAYS_PER_WEEK
|
|
),
|
|
) :
|
|
`${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
|
|
while (cursor <= range.to) {
|
|
const end =
|
|
granularity === 'week' ?
|
|
addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET) :
|
|
endOfMonth(cursor);
|
|
buckets.push({
|
|
from: cursor < range.from ? range.from : cursor,
|
|
to: end > range.to ? range.to : end,
|
|
});
|
|
cursor = addDays(end, NEXT_DAY_OFFSET);
|
|
}
|
|
return buckets;
|
|
}
|
|
|
|
export function rangeDays(range: DateOnlyRange): number {
|
|
return (
|
|
Math.round(
|
|
(utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) /
|
|
MILLISECONDS_PER_DAY,
|
|
) + INCLUSIVE_DAY_OFFSET
|
|
);
|
|
}
|
|
|
|
function assertRange(range: DateOnlyRange, today: string): void {
|
|
try {
|
|
dateParts(range.from);
|
|
dateParts(range.to);
|
|
} catch {
|
|
throw invalidPeriod();
|
|
}
|
|
if (range.from > range.to || range.to > today) throw invalidPeriod();
|
|
}
|
|
|
|
function addDays(value: string, days: number): string {
|
|
const date = utcDate(value);
|
|
date.setUTCDate(date.getUTCDate() + days);
|
|
return date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
|
}
|
|
|
|
function dates(range: DateOnlyRange): string[] {
|
|
const result: string[] = [];
|
|
for (let date = range.from; date <= range.to; date = addDays(date, NEXT_DAY_OFFSET)) {
|
|
result.push(date);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function firstOfMonth(value: string): string {
|
|
return `${value.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
|
|
}
|
|
|
|
function endOfMonth(value: string): string {
|
|
const [year, month] = dateParts(firstOfMonth(value));
|
|
return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
|
}
|
|
|
|
function isFullMonth(range: DateOnlyRange): boolean {
|
|
return (
|
|
range.from === firstOfMonth(range.from) && range.to === endOfMonth(range.to)
|
|
);
|
|
}
|
|
|
|
function previousMonth(from: string): DateOnlyRange {
|
|
const last = addDays(firstOfMonth(from), PREVIOUS_DAY_OFFSET);
|
|
return { from: firstOfMonth(last), to: last };
|
|
}
|
|
|
|
function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange {
|
|
return { from: addDays(range.from, days), to: addDays(range.to, days) };
|
|
}
|
|
|
|
function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
|
|
const shift = (value: string) => {
|
|
const [year, month, day] = dateParts(value);
|
|
const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`;
|
|
return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result ?
|
|
result :
|
|
null;
|
|
};
|
|
const from = shift(range.from);
|
|
const to = shift(range.to);
|
|
return from && to ? { from, to } : null;
|
|
}
|
|
|
|
function invalidPeriod(): BadRequestException {
|
|
return new BadRequestException({ code: 'INVALID_INCOME_PERIOD' });
|
|
}
|