import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time'; const UTC_MONTH_INDEX_OFFSET = 1; const NEXT_DAY_OFFSET = 1; const MOSCOW_UTC_OFFSET_HOURS = 3; export type DateOnlyRange = { from: string; to: string }; const moscow = 'Europe/Moscow'; const moscowFormatter = new Intl.DateTimeFormat('en-CA', { timeZone: moscow, year: 'numeric', month: '2-digit', day: '2-digit', }); export function moscowDate(value: Date): string { const parts = moscowFormatter.formatToParts(value); const get = (type: string) => parts.find((part) => part.type === type)?.value; return `${get('year')}-${get('month')}-${get('day')}`; } // Inclusive Moscow calendar dates become a half-open instant range, capped at now. export function dateOnlyBounds( range: DateOnlyRange, now = new Date(), ): { from: Date; to: Date } { const from = dateParts(range.from); const end = dateParts(range.to); const to = new Date( Date.UTC(end[0], end[1] - UTC_MONTH_INDEX_OFFSET, end[2] + NEXT_DAY_OFFSET, -MOSCOW_UTC_OFFSET_HOURS), ); return { from: new Date( Date.UTC(from[0], from[1] - UTC_MONTH_INDEX_OFFSET, from[2], -MOSCOW_UTC_OFFSET_HOURS), ), to: to > now ? now : to, }; } export function dateParts(value: string): [number, number, number] { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); if (!match) throw new Error('Invalid date'); const parts = [Number(match[1]), Number(match[2]), Number(match[3])] as [ number, number, number, ]; if (utcDate(value).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== value) { throw new Error('Invalid date'); } return parts; } export function utcDate(value: string): Date { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); if (!match) return new Date(NaN); return new Date( Date.UTC(Number(match[1]), Number(match[2]) - UTC_MONTH_INDEX_OFFSET, Number(match[3])), ); }