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'; export enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' } const MONDAY_WEEKDAY_INDEX = 1; const INCLUSIVE_DAY_OFFSET = 1; const NEXT_DAY_OFFSET = 1; const PREVIOUS_MONTH_LAST_DAY = 0; const DAILY_BUCKET_MAX_DAYS = 45; const WEEKLY_BUCKET_MAX_DAYS = 180; const DAYS_PER_WEEK = 7; const ISO_MONTH_LENGTH = 7; export type IncomeBucketGranularity = 'day' | 'month' | 'week'; export function planIncomePeriod( from: string | undefined, to: string | undefined, ): { granularity: IncomeBucketGranularity; selected: DateOnlyRange; } { 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); return { selected, 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 invalidPeriod(): BadRequestException { return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod }); }