Time Math Is Harder Than It Looks: 6 Duration Bugs and How to Avoid Them

Time Math Is Harder Than It Looks: 6 Duration Bugs and How to Avoid Them

时间计算比看起来更难:6 个时长计算 Bug 及避坑指南

Adding two durations sounds like first-grade math. 2:45 plus 1:30 — easy, right? Then a ticket comes in: a user logged 11:45 PM → 7:15 AM and your timesheet says they worked negative 16 hours. Welcome to time arithmetic, where the obvious answer is usually wrong. Here are six duration bugs I keep seeing in code reviews, and the mental model that makes them go away.

将两个时长相加听起来像是小学数学题。2:45 加上 1:30 —— 很简单,对吧?但随后你收到一张工单:用户记录了从晚上 11:45 到次日早上 7:15 的工作时间,而你的工时表显示他们工作了负 16 小时。欢迎来到时间算术的世界,在这里,显而易见的答案通常都是错的。以下是我在代码审查中经常看到的六个时长计算 Bug,以及能够帮你彻底解决它们的思维模型。

1. Minutes are base-60, not base-100

1. 分钟是 60 进制,而非 100 进制

The number-one duration bug is treating 1:30 as the decimal 1.30. It isn’t — it’s 1.5 hours. If you store time as HH:MM strings and do arithmetic on the pieces without normalizing, you get garbage.

最常见的时长 Bug 就是把 1:30 当作十进制的 1.30 来处理。其实不然——它是 1.5 小时。如果你以 HH:MM 字符串格式存储时间,并在不进行归一化处理的情况下对各部分进行算术运算,你得到的结果将是一团糟。

// WRONG: treats minutes like a decimal fraction
const hours = 2.45 + 1.30; // 3.75 ❌ (you meant 2h45m + 1h30m)

// RIGHT: normalize to a single base unit first
const toMinutes = (h, m) => h * 60 + m;
const total = toMinutes(2, 45) + toMinutes(1, 30); // 255 minutes
const fmt = `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`; // "4:15" ✅

Rule: convert everything to one base unit (seconds or minutes), do the math, convert back at the end. When I just need to confirm a result by hand before trusting my code, I’ll punch the two values into a time calculator and check the output matches — faster than adding a console.log and re-running.

规则:将所有数值转换为统一的基准单位(秒或分钟),进行计算,最后再转换回来。当我在信任代码之前需要手动确认结果时,我会把这两个值输入到时间计算器中,检查输出是否一致——这比添加 console.log 并重新运行代码要快得多。

2. Crossing midnight makes durations go negative

2. 跨越午夜会导致时长变为负数

The 11:45 PM → 7:15 AM case. If end < start, the interval wrapped past midnight. Naive subtraction gives a negative number.

处理晚上 11:45 到早上 7:15 的情况。如果结束时间小于开始时间,说明时间跨越了午夜。直接相减会得到一个负数。

let diff = endMin - startMin;
if (diff < 0) diff += 24 * 60; // add a full day to unwrap

This bites overnight shifts, sleep trackers, and anything spanning a day boundary. The fix is one line, but only if you remember the case exists.

这在处理夜班、睡眠追踪器以及任何跨越日期边界的场景时都会出问题。修复方法只有一行代码,但前提是你得记得考虑这种情况。

3. 12-hour vs 24-hour parsing

3. 12 小时制与 24 小时制的解析

12:00 PM is noon. 12:00 AM is midnight. Almost every hand-rolled AM/PM parser gets the 12 case backwards because the conversion isn’t +12 for PM — it’s special-cased at 12.

12:00 PM 是中午,12:00 AM 是午夜。几乎每一个手写的 AM/PM 解析器都会把 12 点的情况搞反,因为 PM 的转换并不是简单的“加 12”——12 点是一个特殊情况。

function to24(h, period) {
  if (period === "AM") return h === 12 ? 0 : h;
  return h === 12 ? 12 : h + 12;
}

If your product serves both US (12h) and most of Europe (24h), normalize on input and store 24h internally. Display formatting is a presentation concern — keep it out of your math layer.

如果你的产品同时服务于美国(12 小时制)和欧洲大部分地区(24 小时制),请在输入时进行归一化,并在内部存储 24 小时制。显示格式化属于展示层面的问题——请将其从你的数学计算层中剥离出来。

4. DST means a “day” isn’t always 24 hours

4. 夏令时(DST)意味着“一天”并不总是 24 小时

Twice a year, a calendar day is 23 or 25 hours long. If you compute durations by subtracting wall-clock times across a DST boundary, you’ll be off by an hour. This is why you never do duration math on local timestamps — convert to UTC (or epoch seconds) first, subtract, then format back to local for display.

每年有两次,日历上的一天会变成 23 或 25 小时。如果你通过跨越夏令时边界的挂钟时间相减来计算时长,结果会产生一小时的误差。这就是为什么永远不要对本地时间戳进行时长计算的原因——请先转换为 UTC(或纪元秒),相减后再格式化回本地时间进行显示。

const start = new Date("2026-03-08T01:30:00-05:00"); // before US spring-forward
const end = new Date("2026-03-08T03:30:00-04:00"); // after
const hours = (end - start) / 3.6e6; // 1 hour, not 2 — DST ate an hour

For calendar-level differences (business days, age, date spans) the same UTC-first principle applies — our date calculator handles that side, working in whole days rather than clock time.

对于日历层面的差异(工作日、年龄、日期跨度),同样的“UTC 优先”原则也适用——我们的日期计算器会处理这部分逻辑,按完整天数而非时钟时间进行计算。

5. Epoch math is your friend — until you mix units

5. 纪元时间计算是你的好帮手——直到你混用了单位

Date.now() returns milliseconds. Unix time() in most backends returns seconds. Postgres EXTRACT(EPOCH ...) returns seconds (as a float). Mix them and you’re off by 1000×.

Date.now() 返回的是毫秒。大多数后端中的 Unix time() 返回的是秒。Postgres 的 EXTRACT(EPOCH ...) 返回的是秒(浮点数)。混用它们会导致 1000 倍的误差。

const ms = Date.now(); // 1780736400000
const sec = Math.floor(ms / 1000); // 1780736400

When I’m debugging a raw epoch value and need to see it as a human time, I keep a unix timestamp converter open rather than mentally dividing by 1000 and squinting.

当我在调试原始纪元数值并需要将其转换为人类可读的时间时,我会打开一个 Unix 时间戳转换器,而不是在脑子里除以 1000 并眯着眼看。

6. Decimal hours for payroll rounding

6. 用于薪资计算的小时小数位舍入

Payroll systems want decimal hours (8.25), not 8:15. The conversion is minutes / 60, but rounding policy matters: some jurisdictions round to the nearest quarter-hour, some truncate. Decide the policy explicitly — don’t let toFixed(2) make it for you.

薪资系统需要的是小时小数(8.25),而不是 8:15。转换公式是 分钟 / 60,但舍入策略很重要:有些司法管辖区要求四舍五入到最近的刻钟,有些则要求截断。请明确决定你的策略——不要让 toFixed(2) 替你做决定。

const decimal = totalMinutes / 60; // 8.25
const quarterRounded = Math.round(decimal * 4) / 4; // nearest 0.25

This is exactly the conversion a time calculator does when it shows both HH:MM and decimal output — handy when you’re reconciling a timesheet against what your code produced.

这正是时间计算器在同时显示 HH:MM 和小数输出时所做的转换——当你需要核对工时表与代码生成的结果时,这非常方便。

The one mental model

核心思维模型

Every one of these bugs comes from doing arithmetic in a representation that isn’t additive. Wall-clock HH:MM strings aren’t additive (base-60, midnight wrap, DST). Local timestamps aren’t additive (DST, timezones). Seconds-since-epoch are additive.

上述每一个 Bug 都源于在一种“不可加”的表示形式上进行算术运算。挂钟时间的 HH:MM 字符串是不可加的(60 进制、午夜回绕、夏令时)。本地时间戳是不可加的(夏令时、时区)。而“自纪元以来的秒数”是可加的。

So: Parse input into a single base unit (epoch seconds, or total minutes for clock durations). Do all math there. Format back to human representation only at the very end. Get that pipeline right and time math stops surprising you.

因此:将输入解析为单一的基准单位(纪元秒,或时钟时长的总分钟数)。所有的计算都在该单位下进行。仅在最后一步才将其格式化为人类可读的表示形式。只要理顺了这个流程,时间计算就不会再让你感到意外了。