Ctrl K
ComunidadGuías…

src/services/date.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
  }
}