not a better man

前端技术

一年中的第一周该如何计算?

a screenshot of a calendar

今天UI 组件组内小伙伴需要在日期组件中实现显示周数的需求,周的开始起点可以是周一,或者周日。这里自然而然会碰到一个问题,就是上一年度的12 月月底与下一年度的 1 月份会出现在同一周里。那是算上一年的周数还是下一年的第一周呢?于是比对了业内UI 组件库设计的比较好的 ant-design

ant-design中英文环境下不同点

在对比了业内使用比较广的 ant-design UI。 发现如下所示,他们在中文的语言环境下。2009年 1 月 1 日 属于 2008 年的 53 周。

中文环境

中文环境下每周的起点是周一,2009 年 12 月 28 日-2010 年 1 月 3 日 是 2009 年的 第53 周。

英文环境

从图中可以看到英文环境下是 2008 年 12 月 28 日到 2009 年 1 月 3 日 属于2009的第一周,此外一周的起点是周日。

chagpt o1 的回答

这里面有个问题,就是时间的上关于第几周的计算,究竟有没有标准。是否有国际标准,是否每个国家的情况并不一样。通过 chatgpt o1 模型查阅情况如下。

不同的标准

ISO 8601

下面是 ios8601 对每年第一个星期的定义:

使用年份和星期表示某一日期的格式形如YYYYWwwD或YYYY-Www-D,YYYY表示年份,其值与年月日格式中的年份略有差别;Www表示该日期所属星期是今年的第几个星期,范围在W01到W53之间;D表示该日是本星期的第几天,范围在1到7之间,每个星期以周一作为第一天。例如1926年8月17日可写成1926-W33-2或1926W332。

每年的第一个星期可以用如下方法决定: 1,本年度第一个星期四所在的星期; 2,1月4日所在的星期; 3,本年度第一个至少有4天在同一星期内的星期; 4,星期一在去年12月29日至今年1月4日以内的星期;

推理可得,如果1月1日是星期一、星期二、星期三或者星期四,它所在星期就是本年第一个星期;如果1月1日是星期五、星期六或星期日,则它所在星期就是上一年第52或者53个日历星期;12月28日总是在一年最后一个星期。

GB/T 7408.1-2023

我国最新的标准是 是GB/T 7408.1-2023 。相关信息如下

总结

ANSI INCITS 30-1997 (R2008) 和 NIST FIPS PUB 4-2 标准

仍以 周日 作为一周的开端。

只要 1 月 1 日在某周内,即视该周为新的一年第一周

• 由于这符合当时美国绝大部分政府单位及民用日历习惯,所以在联邦机构数据处理上使用时不会造成太大混淆。

所以 ant-design 在切换为英语的时候,采用的是美国传统用法。美国在很多度量单位保留非国际单位制(非 SI 制)的使用习惯。如长度与距离,重量,体积,温度等度量单位方面。

这个里面有个细节,当我们编写 与 date 相关UI 组件时,需要考虑不同语种与习惯,并不是所有国家都是按照国际标准进行定义的。

相关代码实现

以 ISO 8601

/**
 * 根据 GB/T 7408.1-2023 / ISO 8601 计算指定日期是该年的第几周
 * (每年的第一周可通过以下方法等效决定:
 *   1) 本年度第一个星期四所在的星期;
 *   2) 1月4日所在的星期;
 *   3) 本年度第一个至少有4天在同一星期内的星期;
 *   4) 星期一在去年12月29日至今年1月4日以内的星期。)
 *
 * 支持以下三种输入格式:
 *   1. Date 对象
 *   2. "YYYYMMDD"(纯数字 8 位)
 *   3. "YYYY-MM-DD"(带短横线分隔)
 *
 * 若传入的参数不符合上述任何一种格式,则会抛出异常。
 *
 * @param {Date | string} input - 要计算的日期对象或可解析的字符串
 * @returns {number} - 该日期是当年的第几周(ISO 8601 / GB/T 7408.1-2023)
 */
function getISOWeekNumber(input) {
  // 1. 解析输入,得到有效的 Date 对象
  const dateObj = parseToDate(input);

  // 2. 转换为基于 UTC 的日期,以免时区差异影响计算
  const tempDate = new Date(Date.UTC(
    dateObj.getFullYear(),
    dateObj.getMonth(),
    dateObj.getDate()
  ));
  
  // 3. ISO 8601 规定的技巧:将当前日期平移到同一周的“星期四”
  //    getUTCDay() 返回 0(周日)~6(周六),若为0改为7(周日视为第7天)
  const dayNum = tempDate.getUTCDay() || 7;
  tempDate.setUTCDate(tempDate.getUTCDate() + 4 - dayNum);
  
  // 4. 计算该周所属年份的年初(UTC)
  const yearStart = new Date(Date.UTC(tempDate.getUTCFullYear(), 0, 1));
  
  // 5. 计算从年初到平移后日期的天数
  const days = Math.floor((tempDate - yearStart) / 86400000) + 1;
  
  // 6. 周次 = ceil(天数 / 7)
  return Math.ceil(days / 7);
}

/**
 * 辅助函数:根据输入格式将其解析为 Date 对象
 * 支持以下三种情况:
 *   1. 原生 Date 对象:确保它是有效日期
 *   2. "YYYYMMDD":纯数字 8 位
 *   3. "YYYY-MM-DD":中间带短横线分隔
 *
 * 其他格式或无效日期会抛出异常
 *
 * @param {Date | string} input
 * @returns {Date}
 */
function parseToDate(input) {
  // 情况 1:本身是 Date 对象
  if (input instanceof Date) {
    if (isNaN(input.getTime())) {
      throw new Error("Invalid Date object");
    }
    return input;
  }
  
  // 情况 2 & 3:字符串,需要匹配正则
  if (typeof input === 'string') {
    // 匹配纯 8 位数字格式 "YYYYMMDD"
    const matchPure = input.match(/^(\d{4})(\d{2})(\d{2})$/);
    // 匹配带短横线的 "YYYY-MM-DD"
    const matchHyphen = input.match(/^(\d{4})-(\d{2})-(\d{2})$/);
    
    if (matchPure) {
      const [ , y, m, d ] = matchPure;
      const year = parseInt(y, 10);
      const month = parseInt(m, 10) - 1; // JS 中月份从 0 开始
      const day = parseInt(d, 10);
      const date = new Date(year, month, day);
      if (isNaN(date.getTime())) {
        throw new Error("Invalid date from 'YYYYMMDD'");
      }
      return date;
    } else if (matchHyphen) {
      const [ , y, m, d ] = matchHyphen;
      const year = parseInt(y, 10);
      const month = parseInt(m, 10) - 1;
      const day = parseInt(d, 10);
      const date = new Date(year, month, day);
      if (isNaN(date.getTime())) {
        throw new Error("Invalid date from 'YYYY-MM-DD'");
      }
      return date;
    } else {
      throw new Error("Invalid date string format");
    }
  }

  // 其他情况一律抛出异常
  throw new Error("Unsupported date type or format");
}

// -------------------- 测试示例 --------------------
try {
  // 示例 1:传入 Date 对象
  const dateObj = new Date(2010, 0, 1); // 2010-01-01
  console.log("Test1 (Date obj):", getISOWeekNumber(dateObj)); // 期望 53

  // 示例 2:传入 "YYYYMMDD"
  console.log("Test2 (YYYYMMDD):", getISOWeekNumber("20100101")); // 期望 53

  // 示例 3:传入 "YYYY-MM-DD"
  console.log("Test3 (YYYY-MM-DD):", getISOWeekNumber("2010-01-01")); // 期望 53

  // 示例 4:不合法输入(应抛出异常)
  console.log("Test4:", getISOWeekNumber("2010/01/01")); 
} catch (err) {
  console.error("Error:", err.message);
}

以周日进行计算

/**
 * 以周日为起始日,计算指定日期是该年的第几周
 * (只要在包含 1 月 1 日的周里,就算作第 1 周)
 * @param {Date} date - 要计算的日期对象
 * @returns {number} - 该日期是当年的第几周
 */
function getSundayStartWeekNumber(date) {
  // 当年的第一天
  const startOfYear = new Date(date.getFullYear(), 0, 1);
  
  // 获取这一天是周几(0=周日,1=周一,...,6=周六)
  const dayOfWeek = startOfYear.getDay(); 
  // 若第一天不是周日,则需要找出当年的第一个“周日”
  const offset = dayOfWeek > 0 ? dayOfWeek : 0;
  const firstSunday = new Date(startOfYear.getFullYear(), 0, 1 - offset);
  
  // 计算差值(毫秒),再转为天数
  const diff = date - firstSunday;
  const dayCount = Math.floor(diff / (1000 * 60 * 60 * 24));
  
  // 每 7 天为一周
  return Math.floor(dayCount / 7) + 1;
}

// 测试:2010 年 1 月 1 日
const testDate2 = new Date(2010, 0, 1);
console.log("周日开头 周次:", getSundayStartWeekNumber(testDate2));

发表评论