diff options
| author | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
| commit | 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch) | |
| tree | b9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/lib/date.ts | |
| download | umami-main.tar.xz umami-main.zip | |
Created from https://vercel.com/new
Diffstat (limited to 'src/lib/date.ts')
| -rw-r--r-- | src/lib/date.ts | 375 |
1 files changed, 375 insertions, 0 deletions
diff --git a/src/lib/date.ts b/src/lib/date.ts new file mode 100644 index 0000000..3c1fd1b --- /dev/null +++ b/src/lib/date.ts @@ -0,0 +1,375 @@ +import { + addDays, + addHours, + addMinutes, + addMonths, + addWeeks, + addYears, + differenceInCalendarDays, + differenceInCalendarMonths, + differenceInCalendarWeeks, + differenceInCalendarYears, + differenceInHours, + differenceInMinutes, + endOfDay, + endOfHour, + endOfMinute, + endOfMonth, + endOfWeek, + endOfYear, + format, + isBefore, + isDate, + isEqual, + isSameDay, + max, + min, + startOfDay, + startOfHour, + startOfMinute, + startOfMonth, + startOfWeek, + startOfYear, + subDays, + subHours, + subMinutes, + subMonths, + subWeeks, + subYears, +} from 'date-fns'; +import { utcToZonedTime } from 'date-fns-tz'; +import { getDateLocale } from '@/lib/lang'; +import type { DateRange } from '@/lib/types'; + +export const TIME_UNIT = { + minute: 'minute', + hour: 'hour', + day: 'day', + week: 'week', + month: 'month', + year: 'year', +}; + +export const DATE_FUNCTIONS = { + minute: { + diff: differenceInMinutes, + add: addMinutes, + sub: subMinutes, + start: startOfMinute, + end: endOfMinute, + }, + hour: { + diff: differenceInHours, + add: addHours, + sub: subHours, + start: startOfHour, + end: endOfHour, + }, + day: { + diff: differenceInCalendarDays, + add: addDays, + sub: subDays, + start: startOfDay, + end: endOfDay, + }, + week: { + diff: differenceInCalendarWeeks, + add: addWeeks, + sub: subWeeks, + start: startOfWeek, + end: endOfWeek, + }, + month: { + diff: differenceInCalendarMonths, + add: addMonths, + sub: subMonths, + start: startOfMonth, + end: endOfMonth, + }, + year: { + diff: differenceInCalendarYears, + add: addYears, + sub: subYears, + start: startOfYear, + end: endOfYear, + }, +}; + +export const DATE_FORMATS = { + minute: 'yyyy-MM-dd HH:mm', + hour: 'yyyy-MM-dd HH', + day: 'yyyy-MM-dd', + week: "yyyy-'W'II", + month: 'yyyy-MM', + year: 'yyyy', +}; + +const TIMEZONE_MAPPINGS: Record<string, string> = { + 'Asia/Calcutta': 'Asia/Kolkata', +}; + +export function normalizeTimezone(timezone: string): string { + return TIMEZONE_MAPPINGS[timezone] || timezone; +} + +export function isValidTimezone(timezone: string) { + try { + const normalizedTimezone = normalizeTimezone(timezone); + Intl.DateTimeFormat(undefined, { timeZone: normalizedTimezone }); + return true; + } catch { + return false; + } +} + +export function getTimezone() { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +export function parseDateValue(value: string) { + const match = value.match?.(/^(?<num>[0-9-]+)(?<unit>hour|day|week|month|year)$/); + + if (!match) return null; + + const { num, unit } = match.groups; + + return { num: +num, unit }; +} + +export function parseDateRange(value: string, locale = 'en-US', timezone?: string): DateRange { + if (typeof value !== 'string') { + return null; + } + + if (value.startsWith('range')) { + const [, startTime, endTime] = value.split(':'); + + const startDate = new Date(+startTime); + const endDate = new Date(+endTime); + const unit = getMinimumUnit(startDate, endDate); + + return { + startDate, + endDate, + value, + ...parseDateValue(value), + unit, + }; + } + + const date = new Date(); + const now = timezone ? utcToZonedTime(date, timezone) : date; + const dateLocale = getDateLocale(locale); + const { num = 1, unit } = parseDateValue(value); + + switch (unit) { + case 'hour': + return { + startDate: num ? subHours(startOfHour(now), num) : startOfHour(now), + endDate: endOfHour(now), + offset: 0, + num: num || 1, + unit, + value, + }; + case 'day': + return { + startDate: num ? subDays(startOfDay(now), num) : startOfDay(now), + endDate: endOfDay(now), + unit: num ? 'day' : 'hour', + offset: 0, + num: num || 1, + value, + }; + case 'week': + return { + startDate: num + ? subWeeks(startOfWeek(now, { locale: dateLocale }), num) + : startOfWeek(now, { locale: dateLocale }), + endDate: endOfWeek(now, { locale: dateLocale }), + unit: 'day', + offset: 0, + num: num || 1, + value, + }; + case 'month': + return { + startDate: num ? subMonths(startOfMonth(now), num) : startOfMonth(now), + endDate: endOfMonth(now), + unit: num ? 'month' : 'day', + offset: 0, + num: num || 1, + value, + }; + case 'year': + return { + startDate: num ? subYears(startOfYear(now), num) : startOfYear(now), + endDate: endOfYear(now), + unit: 'month', + offset: 0, + num: num || 1, + value, + }; + } +} + +export function getOffsetDateRange(dateRange: DateRange, offset: number) { + if (offset === 0) { + return dateRange; + } + + const { startDate, endDate, unit, num, value } = dateRange; + + const change = num * offset; + const { add } = DATE_FUNCTIONS[unit]; + const { unit: originalUnit } = parseDateValue(value) || {}; + + switch (originalUnit) { + case 'day': + return { + ...dateRange, + offset, + startDate: addDays(startDate, change), + endDate: addDays(endDate, change), + }; + case 'week': + return { + ...dateRange, + offset, + startDate: addWeeks(startDate, change), + endDate: addWeeks(endDate, change), + }; + case 'month': + return { + ...dateRange, + offset, + startDate: addMonths(startDate, change), + endDate: addMonths(endDate, change), + }; + case 'year': + return { + ...dateRange, + offset, + startDate: addYears(startDate, change), + endDate: addYears(endDate, change), + }; + default: + return { + startDate: add(startDate, change), + endDate: add(endDate, change), + offset, + value, + unit, + num, + }; + } +} + +export function getAllowedUnits(startDate: Date, endDate: Date) { + const units = ['minute', 'hour', 'day', 'month', 'year']; + const minUnit = getMinimumUnit(startDate, endDate); + const index = units.indexOf(minUnit === 'year' ? 'month' : minUnit); + + return index >= 0 ? units.splice(index) : []; +} + +export function getMinimumUnit(startDate: number | Date, endDate: number | Date) { + if (differenceInMinutes(endDate, startDate) <= 60) { + return 'minute'; + } else if (differenceInHours(endDate, startDate) <= 48) { + return 'hour'; + } else if (differenceInCalendarMonths(endDate, startDate) <= 6) { + return 'day'; + } else if (differenceInCalendarMonths(endDate, startDate) <= 24) { + return 'month'; + } + + return 'year'; +} + +export function maxDate(...args: Date[]) { + return max(args.filter(n => isDate(n))); +} + +export function minDate(...args: any[]) { + return min(args.filter(n => isDate(n))); +} + +export function getCompareDate(compare: string, startDate: Date, endDate: Date) { + if (compare === 'yoy') { + return { compare, startDate: subYears(startDate, 1), endDate: subYears(endDate, 1) }; + } + + if (compare === 'prev') { + const diff = differenceInMinutes(endDate, startDate); + + return { compare, startDate: subMinutes(startDate, diff), endDate: subMinutes(endDate, diff) }; + } + + return {}; +} + +export function getDayOfWeekAsDate(dayOfWeek: number) { + const startOfWeekDay = startOfWeek(new Date()); + const daysToAdd = [0, 1, 2, 3, 4, 5, 6].indexOf(dayOfWeek); + let currentDate = addDays(startOfWeekDay, daysToAdd); + + // Ensure we're not returning a past date + if (isSameDay(currentDate, startOfWeekDay)) { + currentDate = addDays(currentDate, 7); + } + + return currentDate; +} + +export function formatDate( + date: string | number | Date, + dateFormat: string = 'PPpp', + locale = 'en-US', +) { + return format(typeof date === 'string' ? new Date(date) : date, dateFormat, { + locale: getDateLocale(locale), + }); +} + +export function generateTimeSeries( + data: { x: string; y: number; d?: string }[], + minDate: Date, + maxDate: Date, + unit: string, + locale: string, +) { + const add = DATE_FUNCTIONS[unit].add; + const start = DATE_FUNCTIONS[unit].start; + const fmt = DATE_FORMATS[unit]; + + let current = start(minDate); + const end = start(maxDate); + + const timeseries: string[] = []; + + while (isBefore(current, end) || isEqual(current, end)) { + timeseries.push(formatDate(current, fmt, locale)); + current = add(current, 1); + } + + const lookup = new Map(data.map(({ x, y, d }) => [formatDate(x, fmt, locale), { x, y, d }])); + + return timeseries.map(t => { + const { x, y, d } = lookup.get(t) || {}; + + return { x: t, d: d ?? x, y: y ?? null }; + }); +} + +export function getDateRangeValue(startDate: Date, endDate: Date) { + return `range:${startDate.getTime()}:${endDate.getTime()}`; +} + +export function getMonthDateRangeValue(date: Date) { + return getDateRangeValue(startOfMonth(date), endOfMonth(date)); +} + +export function isInvalidDate(date: any) { + return date instanceof Date && Number.isNaN(date.getTime()); +} |