日历页:周数规则、月历年历与纯日期算法
博客的 /calendar/ 全屏日历:月历/年历双视图、周数显示、日期跳转,以及"第一个完整周"周数规则的手写实现。
博客导航栏里的 Calendar(/calendar/)是一个全屏日历页面,支持月历/年历双视图、周数显示和日期跳转。有意思的部分是周数规则,展开讲讲。
功能
- 月历:6 行 × 7 列网格,左侧一列周数,今天描边、点击选中,底部显示
Tue, September 1, 2026 · Week 35 - 年历:12 个月份缩略图,点击月名或任意日期跳到对应月历
- 日期跳转:原生日期选择器 + Go,可跳任意日期(闰年安全);Today 一键回当月
- 两个可切换设置(持久化):
- 周起始日:Sunday(默认)/ Monday
- 周数规则:完整周(默认)/ ISO 8601
- 主题:浅色/暗色/自动三态,与时钟页一致
周数:两种规则
规则一:第一个完整周(默认)
某年的第 1 周 = 该年内第一个完整包含的周(起始日对齐的 7 天全部落在此年内)。
以 2026 年(周日起始)为例:1 月 1 日是周四,1 月 1 日所在的那周从 2025 年 12 月 28 日开始——不是完整周。2026 年第 1 周 = 1 月 4 日(周日)~ 1 月 10 日。于是 1 月 1 日~3 日这几天沿用 2025 年的最后一个周号;年末同理,12 月末的残缺周继续编号(如 W52)。
实现出奇地简洁——跨年残缺周的归属年总是“12 月那一侧的年份”(年初残缺周属于上年、年末残缺周继续本年编号,两种情况统一):
function fullWeekNum(d: Date, startDay: number): { week: number; year: number } {
const ws = startOfWeek(d, startDay); // 对齐到本周起始日
const y = ws.getFullYear(); // 残缺周 → 归 12 月侧年份
const week = (ws - week1Start(y, startDay)) / (7 * DAY_MS) + 1;
return { week, year: y };
}
week1Start(y):1 月 1 日恰好是起始日则第 1 周从 1 月 1 日开始,否则从 1 月 1 日之后的第一个起始日开始。
规则二:ISO 8601
国际标准:周一为一周之始,第 1 周是包含当年首个周四的那一周(等价于“含 1 月 4 日的周”)。例:2026-09-01 是 ISO 第 36 周;2027-01-01(周五)属于 2026 年的 W53。
一个组合细节:当网格是周日起始而周数选 ISO 时,一个日历行可能横跨两个 ISO 周(周日属于上一 ISO 周,周一~周六属于下一周)。约定周数列显示该行周四所在的 ISO 周号——周四是一周的主心骨,两种规则下取周四都是无歧义的锚点。
闰年怎么处理?
代码从不硬编码“一年多少天”。取某月天数用 new Date(year, m + 1, 0).getDate()——下个月的“第 0 天”自动回退到本月最后一天,2 月自然返回 28 或 29。格里高利历闰年规则(4 年一闰、百年不闰、400 年再闰)由 JS Date 内建处理:2000 年闰、2100 年不闰。
月历网格
从“本月 1 号所在周的起始日”开始铺 42 格(6 行),周数列就是每行的周号——因为行本身就是一个完整的周,行与周严格一一对应,这是把周起始日做成可切换后依然成立的性质。
工程组织
- 纯日期算法抽在
src/scripts/calendar/week.ts:无 DOM、无副作用,可以直接用 tsx 单测(29 个用例:跨年边界、2000/2100 闰年、ISO 已知值交叉验证) - 页面控制器
calendar.ts只做状态管理 + DOM 渲染(createElement/textContent,不拼 HTML 字符串) - 主题、角标按钮等模式与
/clock/完全同构
踩过的坑
Astro 的 scoped style 编译成 .cls[data-astro-cid-xxx],而运行时 createElement 生成的元素带不上这个属性——日历网格、计算器的变量 chips 都中过这招,样式静默失效。运行时生成内容的组件要用 is:global 样式,或给 [hidden] 这类被 display: grid 覆盖的属性补显式规则。