import { i18n } from '~/modules/i18n' import { getFormatLocale, resolveFormatLocale } from '~/services/formatLocale' const ZONELESS_ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/ const DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})$/ function parseDatePreservingUtc(date: Date | string | undefined | null): Date | null { if (!date) return null if (date instanceof Date) return Number.isNaN(date.getTime()) ? null : date const dateOnlyMatch = DATE_ONLY_RE.exec(date) if (dateOnlyMatch) { const [, year, month, day] = dateOnlyMatch const parsedYear = Number(year) const parsedMonth = Number(month) const parsedDay = Number(day) const parsed = new Date(parsedYear, parsedMonth - 1, parsedDay) if ( Number.isNaN(parsed.getTime()) || parsed.getFullYear() !== parsedYear || parsed.getMonth() !== parsedMonth - 1 || parsed.getDate() !== parsedDay ) { return null } return parsed } const normalized = ZONELESS_ISO_DATETIME_RE.test(date) ? `${date}Z` : date const parsed = new Date(normalized) return Number.isNaN(parsed.getTime()) ? null : parsed } export function resolveDateLocale(formatLocale?: string | null): string { return resolveFormatLocale(formatLocale) } export function getDateLocale(): string { return getFormatLocale() } /** * Format a date using the account date and number convention. */ export function formatLocalDate(date: Date | string | undefined | null): string { const d = parseDatePreservingUtc(date) if (!d) return '' return d.toLocaleDateString(getDateLocale()) } /** * Format a date with month name and day using the account date and number convention. */ export function formatLocalDateLong(date: Date | string | undefined | null): string { const d = parseDatePreservingUtc(date) if (!d) return '' return d.toLocaleDateString(getDateLocale(), { month: 'long', day: 'numeric' }) } /** * Format a compact date for dense chart axes using the account date and number convention. */ export function formatLocalDateShort(date: Date | string | undefined | null): string { const d = parseDatePreservingUtc(date) if (!d) return '' return d.toLocaleDateString(getDateLocale(), { month: 'short', day: 'numeric' }) } /** * Format a time using the account date and number convention. */ export function formatLocalTime(date: Date | string | undefined | null): string { const d = parseDatePreservingUtc(date) if (!d) return '' return d.toLocaleTimeString(getDateLocale(), { hour: 'numeric', minute: '2-digit' }) } /** * Format a month/year bucket using the account date and number convention. */ export function formatLocalMonthYear(date: Date | string | undefined | null): string { const d = parseDatePreservingUtc(date) if (!d) return '' return d.toLocaleDateString(getDateLocale(), { month: 'short', year: 'numeric' }) } /** * Format a date/time using the account date and number convention. */ export function formatLocalDateTime(date: Date | string | undefined | null): string { const d = parseDatePreservingUtc(date) if (!d) return '' return d.toLocaleString(getDateLocale(), { dateStyle: 'medium', timeStyle: 'short' }) } /** * Format a date/time with seconds using the account date and number convention. */ export function formatLocalDateTimeWithSeconds(date: Date | string | undefined | null): string { const d = parseDatePreservingUtc(date) if (!d) return '' return d.toLocaleString(getDateLocale(), { dateStyle: 'medium', timeStyle: 'medium' }) } export function formatUtcDateTimeAsLocal(date: Date | string | undefined | null): string { return formatLocalDateTime(date) } export function formatDate(date: string | undefined) { return formatLocalDateTime(date) } export function getDaysInCurrentMonth() { const date = new Date() return new Date( date.getFullYear(), date.getMonth() + 1, 0, ).getDate() } /** Days in the current UTC calendar month (for UTC-bucketed dashboard charts). */ export function getDaysInCurrentUtcMonth() { const date = new Date() return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate() } export function getCurrentDayMonth() { const date = new Date() return date.getDate() } /** Inclusive UTC day start and end instants for log / range navigation. */ export function getUtcDayBounds(date: Date) { const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0)) const end = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999)) return { start, end } } /** * Start of the UTC calendar day. * Dashboard daily stats (daily_mau, daily_version, CF Analytics) are bucketed in UTC, * so chart ranges and API date params must use UTC day boundaries — not the browser's local midnight. */ export function normalizeToUtcStartOfDay(date: Date = new Date()) { const normalized = new Date(date) normalized.setUTCHours(0, 0, 0, 0) return normalized } export function addUtcDays(date: Date, days: number) { const next = new Date(date) next.setUTCDate(next.getUTCDate() + days) return next } /** * Parse a billing / chart range boundary as a UTC calendar day. * Date-only strings stay on that UTC day (unlike parseDatePreservingUtc local midnight). */ function parseUtcRangeBoundary(date: Date | string | undefined | null): Date | null { if (!date) return null if (date instanceof Date) return Number.isNaN(date.getTime()) ? null : date const dateOnlyMatch = DATE_ONLY_RE.exec(date) if (dateOnlyMatch) { const [, year, month, day] = dateOnlyMatch const parsedYear = Number(year) const parsedMonth = Number(month) const parsedDay = Number(day) const parsed = new Date(Date.UTC(parsedYear, parsedMonth - 1, parsedDay)) if ( Number.isNaN(parsed.getTime()) || parsed.getUTCFullYear() !== parsedYear || parsed.getUTCMonth() !== parsedMonth - 1 || parsed.getUTCDate() !== parsedDay ) { return null } return parsed } const normalized = ZONELESS_ISO_DATETIME_RE.test(date) ? `${date}Z` : date const parsed = new Date(normalized) return Number.isNaN(parsed.getTime()) ? null : parsed } /** * YYYY-MM-DD for API / DB date filters using the UTC calendar day. * Never derive this from local midnight via toISOString() — that shifts the day for viewers east of UTC. */ export function formatUtcDateParam(date: Date | string = new Date()) { let parsed: Date if (typeof date === 'string' && DATE_ONLY_RE.test(date)) parsed = new Date(`${date}T00:00:00.000Z`) else if (typeof date === 'string' && ZONELESS_ISO_DATETIME_RE.test(date)) parsed = new Date(`${date}Z`) else parsed = new Date(date) if (Number.isNaN(parsed.getTime())) return '' return parsed.toISOString().slice(0, 10) } /** Local Date with the same Y-M-D as the UTC calendar day (for localized chart labels). */ export function utcCalendarDayAsLocalDate(date: Date) { return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) } function getDatesInRange(startDate: Date, endDate: Date) { const dates = [] const currentDate = normalizeToUtcStartOfDay(startDate) const normalizedEndDate = normalizeToUtcStartOfDay(endDate) while (currentDate.getTime() <= normalizedEndDate.getTime()) { dates.push(utcCalendarDayAsLocalDate(currentDate)) currentDate.setUTCDate(currentDate.getUTCDate() + 1) } return dates } export function chartLabelCountForPeriodDays(dayCount: number) { const days = Number.isFinite(dayCount) ? Math.max(1, Math.floor(dayCount)) : 30 return days === 1 ? 2 : days } export function getLastNUtcDaysRange(dayCount: number) { const endDate = normalizeToUtcStartOfDay(new Date()) // 1 day = last 24h: yesterday + today so charts have two points. const startDate = addUtcDays(endDate, -(chartLabelCountForPeriodDays(dayCount) - 1)) return { startDate, endDate } } export function getChartDateRange(useBillingPeriod: boolean, billingStart?: Date | string | null, billingEnd?: Date | string | null, dayCount = 30) { if (useBillingPeriod) { const startDate = normalizeToUtcStartOfDay(parseUtcRangeBoundary(billingStart) ?? new Date()) const endDate = normalizeToUtcStartOfDay(parseUtcRangeBoundary(billingEnd) ?? new Date()) return { startDate, endDate } } return getLastNUtcDaysRange(dayCount) } export function generateChartDayLabels(startDate: Date, endDate: Date) { return getDatesInRange(startDate, endDate).map(formatLocalDateShort) } export function generateMonthDays(useBillingPeriod: boolean, cycleStart: Date, cycleEnd: Date) { const { startDate, endDate } = useBillingPeriod ? { startDate: cycleStart, endDate: cycleEnd } : getChartDateRange(false) return getDatesInRange(startDate, endDate).map(formatLocalDateShort) } /** * Format a date as a relative time string (e.g., "2 hours ago", "3 days ago") */ export function formatDistanceToNow(date: Date | string | undefined | null): string { if (!date) return '' const d = typeof date === 'string' ? new Date(date) : date if (Number.isNaN(d.getTime())) return '' const now = new Date() const diffInMs = now.getTime() - d.getTime() const diffInSeconds = Math.floor(diffInMs / 1000) const diffInMinutes = Math.floor(diffInSeconds / 60) const diffInHours = Math.floor(diffInMinutes / 60) const diffInDays = Math.floor(diffInHours / 24) if (diffInSeconds < 60) { return i18n.global.t('just-now') } else if (diffInMinutes < 60) { return i18n.global.t('minutes-ago', { count: diffInMinutes }) } else if (diffInHours < 24) { return i18n.global.t('hours-ago', { count: diffInHours }) } else if (diffInDays < 30) { return i18n.global.t('days-ago', { count: diffInDays }) } else { return formatLocalDate(d) } }