calendar-day.js 2.21 KB
/**
 * 「某时区的自然日」三件套。每日领取按 America/Los_Angeles 自然日结算,商业化看板按同一口径分桶,
 * 两边必须用同一份换算,否则领取日和报表日会差一天。
 */

const DAY_FORMATTERS = new Map()

function dayFormatter(timezone) {
  let formatter = DAY_FORMATTERS.get(timezone)
  if (formatter === undefined) {
    formatter = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' })
    DAY_FORMATTERS.set(timezone, formatter)
  }
  return formatter
}

/**
 * 一个时刻落在 `timezone` 的哪个自然日。
 * @param {Date} instant
 * @param {string} timezone IANA 时区名
 * @returns {string} `YYYY-MM-DD`
 */
export function calendarDayOf(instant, timezone) {
  const value = Object.fromEntries(dayFormatter(timezone).formatToParts(instant).map(part => [part.type, part.value]))
  return `${value.year}-${value.month}-${value.day}`
}

/**
 * `timezone` 里某个自然日的零点对应的 UTC 时刻。
 * 先按 UTC 零点猜一个时刻,读出该时刻在目标时区的偏移再减回去;夏令时切换日的偏移以那一刻为准。
 * @param {string} date `YYYY-MM-DD`
 * @param {string} timezone IANA 时区名
 * @returns {Date}
 */
export function midnightInTimezone(date, timezone) {
  const guess = new Date(`${date}T00:00:00.000Z`)
  const offset = new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'longOffset' })
    .formatToParts(guess)
    .find(part => part.type === 'timeZoneName')
    .value
  const match = /^GMT(?:([+-])(\d\d):(\d\d))?$/.exec(offset)
  if (match === null) throw new Error(`Cannot resolve timezone offset: ${timezone}`)
  if (match[1] === undefined) return guess
  const minutes = Number(match[2]) * 60 + Number(match[3])
  const signedMinutes = match[1] === '+' ? minutes : -minutes
  return new Date(guess.getTime() - signedMinutes * 60 * 1000)
}

/**
 * 自然日加减天数,纯日历运算,不涉及时区。
 * @param {string} date `YYYY-MM-DD`
 * @param {number} days
 * @returns {string}
 */
export function shiftCalendarDay(date, days) {
  const at = new Date(`${date}T00:00:00.000Z`)
  at.setUTCDate(at.getUTCDate() + days)
  return at.toISOString().slice(0, 10)
}