freeCodeCamp 每日编程挑战解析:JavaScript 实现 “Due Date“(9 个月后日期计算)
2026/9/10 17:39:00 网站建设 项目流程

freeCodeCamp 每日编程挑战解析:JavaScript 实现 "Due Date"(9 个月后日期计算)

【免费下载链接】freeCodeCampfreeCodeCamp.org's open-source codebase and curriculum. Learn math, programming, and computer science for free.项目地址: https://gitcode.com/GitHub_Trending/fr/freeCodeCamp

本篇技术指南围绕 freeCodeCamp 开源仓库中的每日编程挑战Challenge 232: Due Date(挑战文档)展开,深入拆解题目要求、全部测试用例、边界条件与参考解法,并结合仓库中该挑战从课程定义到 API 服务、种子脚本的完整落地链路,帮助读者彻底掌握「在YYYY-MM-DD字符串上做纯数学日期推算」这一经典面试题型的解题范式。读完本文,你将能独立写出正确处理跨年、闰年与月末溢出的日期加法实现,并理解 freeCodeCamp 每日编程挑战的工程化运行机制。

题目解读:把一个日期字符串往后推 9 个月

题目要求实现函数getDueDate(dateStr):给定一个格式为"YYYY-MM-DD"的日期字符串,返回9 个月后的日期字符串,输出同样保持"YYYY-MM-DD"格式。题目还特别规定了一条关键规则:

如果 9 个月后的那个月不存在原始的天数(例如 5 月 29 日加 9 个月落到 2 月,而 2 月没有 29 日),则返回该月的最后一天。

这意味着题目明确禁止依赖 JavaScriptDate对象“自动回绕”的隐式行为——例如new Date(2026, 1, 29)会被静默地解释为 3 月 1 日,这正是题目想让你避免的错误。解题的核心是自己完成月份进位、跨年修正、天数截断这三步纯算术运算

测试用例拆解:五个用例覆盖四种情形

文档中的--hints--提供了 5 个断言,它们共同划定了实现的验收边界:

调用期望结果覆盖的关键点
getDueDate("2025-03-30")"2025-12-30"普通加法,月份 3+9=12,不跨年
getDueDate("2025-04-27")"2026-01-27"月份 4+9=13,发生跨年进位
getDueDate("2025-05-29")"2026-02-28"跨年且目标月无 29 日,截断到月末(非闰年 2 月)
getDueDate("2026-06-30")"2027-03-30"常规加法,年份递增
getDueDate("2026-10-11")"2027-07-11"月份 10+9=19,跨两年进位(10 月加 9 个月越过次年 1 月到 7 月)

可以观察到:"2025-05-29"这个用例特意选择了非闰年 2026,验证实现必须区分平年(2 月 28 天)与闰年;同时"2025-04-27""2026-10-11"两个用例分别覆盖了“进位一年”和“进位跨两年”的情况,说明月份进位必须用统一的模运算处理,而不是简单的if (month > 12)单次判断。

逐步推导:从字符串解析到日期回填

参考解法的实现位于挑战文档的--solutions--部分,其思路可以分为四个清晰的阶段。我们先推演整体流程,再逐行精读代码。

第一步:解析字符串为数字三元组

输入是"YYYY-MM-DD"字符串,最稳妥的做法是split("-")map(Number)转为[year, month, day]三个数字。注意month在此处是1~12 的自然月份(不是 JavaScriptDate的 0 起始月份),这对后续算术运算至关重要。

第二步:月份加法与跨年修正

month加上 9 后可能超过 12,超过的部分要换算为“额外增加的年份”。通用的做法是:

  • year += Math.floor((month - 1) / 12):每满 12 个月进 1 年;
  • month = ((month - 1) % 12) + 1:把月份归位到 1~12。

先减 1 再取模、最后加 1 的写法,是为了让取模结果落在 1~12 而非 0~11(否则会出现 12 月被算成 0 月的错误)。以"2026-10-11"为例:10 + 9 = 19Math.floor(18 / 12) = 1,年份变为 2027;(18 % 12) + 1 = 7,月份变为 7,得到"2027-07"

第三步:月末溢出截断

这是本题唯一需要“查询日历”的地方:目标月有几天?参考解法用了一个精妙的技巧——

const daysInMonth = new Date(year, month, 0).getDate();

new Date(year, month, 0)中传入day = 0会让Date对象回落到上个月的最后一天,因此.getDate()直接返回目标月(这里的month是 1~12 自然月,而Date构造器期望 0 起始月,两者恰好相差 1,0恰好指向上个月月末)的总天数。这个技巧自动处理了平年/闰年的 2 月差异:new Date(2026, 2, 0).getDate()返回 28,而new Date(2028, 2, 0).getDate()返回 29。得到当月天数后,若原始day超出它,就把day截断为daysInMonth

第四步:补零格式化输出

年份直接字符串拼接,月份和日期用String().padStart(2, "0")补齐两位,保证输出恒为"YYYY-MM-DD"标准格式。

参考实现逐行精读

以下为挑战文档--solutions--中的官方参考实现(与仓库原文一致):

function getDueDate(dateStr) { let [year, month, day] = dateStr.split("-").map(Number); month += 9; year += Math.floor((month - 1) / 12); month = ((month - 1) % 12) + 1; const daysInMonth = new Date(year, month, 0).getDate(); if (day > daysInMonth) { day = daysInMonth; } const mm = String(month).padStart(2, "0"); const dd = String(day).padStart(2, "0"); return `${year}-${mm}-${dd}`; }

逐行要点:

  1. split("-").map(Number):一行同时完成字符串拆分与类型转换;由于数组解构,yearmonthday均为number
  2. month += 9:直接累加 9 个月,允许暂时超过 12,交给下一步归一化。
  3. year += Math.floor((month - 1) / 12):进位年数。对month = 19的情形,(19-1)/12 = 1.5,取整为 1。
  4. month = ((month - 1) % 12) + 1:将月份映射回 1~12。
  5. new Date(year, month, 0).getDate():取目标月最后一天。这是整道题最值得记忆的 API 技巧,避免了手写“大月小月 + 闰年判断”的分支逻辑。
  6. if (day > daysInMonth):溢出才截断;未溢出则保留原day,保证"2025-04-27"这类用例原样输出 27。
  7. padStart(2, "0"):把1格式化为"01",维持字符串格式契约。

复杂度上,该实现为 O(1) 时间、O(1) 空间——不涉及任何循环与日期对象运算链,性能上没有问题,这也是此类纯算术解法的典型特征。

边界情况与易错点自查

在动手实现或审查他人代码时,建议针对以下几类输入做专项验证(可用上面的 5 个官方断言 + 以下补充用例自测):

  • 跨年进位"2025-04-27""2026-01-27",验证month = 13时年份 +1。
  • 连续跨两年"2026-10-11""2027-07-11",验证month = 19时年份 +1 且月份正确回落。
  • 非闰年 2 月末溢出"2025-05-29""2026-02-28",验证 2026 年 2 月只有 28 天。
  • 闰年 2 月不溢出:如"2027-05-29""2028-02-29",验证 2028 年 2 月可保留 29 日。
  • 恰好落在月末不截断"2025-03-30""2025-12-30",12 月有 31 天,30 日原样保留。
  • 月度衔接(大月→小月):如"2025-03-31""2025-12-31"(12 月有 31 日)与"2025-04-30""2026-01-30"(1 月有 31 日),此类用例可验证截断逻辑只在必要时触发。

常见错误包括:忘记padStart导致输出"2025-1-5"这类不合规格式;把month当成 0 起始月直接传给new Date(year, month, 0)以外的运算;以及用setMonth/getMonth组合处理导致日期被Date静默回绕(例如 2 月 29 日加 9 个月被解释成 3 月 1 日而非 2 月 28 日)。

从挑战到产品:该题在 freeCodeCamp 工程链路中的位置

"Due Date" 并非孤立存在的练习题,它是 freeCodeCamp 仓库中Daily Coding Challenge(每日编程挑战)体系的一员。理解它在整个工程中的流转,有助于把握题目的定位与运行环境。

1. 课程层:模块定义与题目编号

本挑战属于daily-coding-challenges-javascript模块(模块结构定义),其中challengeOrder数组按idtitle有序登记了从 "Challenge 1: Vowel Balance" 起的全部题目,本文的题目对应id: 69b1028d6e265413d0198a2b、标题 "Challenge 232: Due Date"。该模块标记为isUpcomingChange: truehelpCategory为 "JavaScript",并启用了usesMultifileEditor。挑战文件本身是标准的 freeCodeCamp 挑战 Markdown:front-matter 声明idtitlechallengeType: 28dashedName,正文以--description----hints----seed----solutions--四个区块组织——这正是仓库中全部挑战的统一文件格式规范。

2. 数据层:种子脚本与图查询

这些挑战文件会被解析并注入到 "Dev Playground" superblock 中,然后由 tools/daily-challenges/seed-daily-challenges.ts 配合 tools/daily-challenges/helpers.ts 读取:脚本通过 GraphQL 端点(http://localhost:8000/___graphql)按superBlock: "dev-playground"block: "daily-coding-challenges-javascript"过滤并排序查询挑战,再把同一编号的 JavaScript 与 Python 版本配对合并(combineChallenges会校验两者的标题、描述、测试数量完全一致),最终以{ challengeNumber, title, date, description, javascript: { tests, challengeFiles }, python: { tests, challengeFiles } }的结构写入 MongoDB 的DailyCodingChallenges集合。值得注意的是,合并时_id直接复用挑战的id字段,它同时作为用户完成记录completedDailyCodingChallenges[]的标识。按照 tools/daily-challenges/README.md 的说明,运行种子脚本需要先复制sample.env.env、安装依赖、以「显示即将上线内容」的方式启动主客户端,再在tools/daily-challenges目录下执行pnpm seed-daily-challenges

3. 服务层:API 按日期拉取题目

种子数据由 API 服务对外提供。在 api/src/daily-coding-challenge/routes/daily-coding-challenge.ts 中注册了 6 个只读 GET 端点,与本挑战直接相关的是/daily-coding-challenge/date/:date(按YYYY-MM-DD查某天的题目)与/daily-coding-challenge/day/:day(按MM-DD查「月日」对应题目,内部会经getSourceDate映射回 2025-08-11 至 2026-08-10 的原始题号周期)。路由层使用 Prisma 查询dailyCodingChallenges表,并用getUtcMidnight(getNowUsCentral())保证「不返回晚于美国中部时间当天」的题目;请求参数与响应体均由 api/src/daily-coding-challenge/schemas/daily-coding-challenge.ts 中的 TypeBox schema 校验(例如date参数强制format: 'date'day参数匹配^\d{2}-\d{2}$,响应中date统一为 ISO 时间字符串)。值得一提的工程细节:与日期相关的工具函数集中在 api/src/daily-coding-challenge/utils/helpers.ts,其中getSourceDate会把任意请求日期映射回原始 365 天挑战周期,甚至对 2 月 29 日做了「回退到 2 月 28 日」的显式处理——这与本挑战「月末溢出截断」的思路如出一辙,都是日期计算中处理「不存在的日期」的典型手法。

4. 展示层:客户端日历组件

在客户端,client/src/components/daily-coding-challenge/ 目录下的widget.tsxcalendar.tsxcalendar-day.tsx等组件负责把每日挑战渲染成日历视图并关联到 API 拉取的数据。这套「课程文件 → GraphQL 提取 → MongoDB 种子 → Fastify API → React 日历组件」的完整流水线,意味着本文所讲的 5 个断言在真实运行时会由挑战运行器逐条执行,而题目的日期格式契约(YYYY-MM-DD)也与 API 层的日期处理保持一致。

小结

"Challenge 232: Due Date" 是一道精巧的纯算术日期题:它考察了字符串解析、带进位的模运算、以及「用new Date(year, month, 0)查询某月天数」这一经典技巧,同时用「月末溢出截断」规则与 JavaScriptDate的自动回绕行为划清界限。掌握本题的解法范式后,你可以轻松推广到任意「加减 N 个月」的场景(如账单周期、订阅到期日、合同履约日期等业务计算),并借助仓库中的 API 与种子脚本理解它从一道课程题目到线上服务的完整落地方式。若想继续深挖,可阅读 api/src/daily-coding-challenge/routes/daily-coding-challenge.test.ts 查看 API 层的日期校验测试,或浏览模块结构文件中挑战 230~240 的题目编排,观察日期类题目在题库中的分布规律。

【免费下载链接】freeCodeCampfreeCodeCamp.org's open-source codebase and curriculum. Learn math, programming, and computer science for free.项目地址: https://gitcode.com/GitHub_Trending/fr/freeCodeCamp

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询