/** * 「某时区的自然日」三件套。每日领取按 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) }