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'; 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 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: IncomeErrorCode.InvalidPeriod }); }