A Month on This Page Is Only Ever 30 Days Long
It started from a small oddity in the reading list: an entry from 29 days ago was labeled "29 days ago", while one from 30 days ago was labeled "1 month ago", and there is no in-between form like "a month and a few days". To find the exact switch point, I ran this page's script on its own for one round: the template is 341 lines / 12362 bytes, the script block executed exactly as written, the DOM only handed out stubs built by id, and the timezone was pinned on the command line into two settings, with getTimezoneOffset measured at -480 minutes and +300 minutes respectively.
The page under test is the timestamp converter, and the relative time lives in the result area of the first tab.
The Six Divisors Are Hard-Coded Constants
relativeTime (L285-L295) is a chain of if branches whose divisors run in order 1000 / 60000 / 3600000 / 86400000 / 2592000000 / 31536000000, with no calendar involved anywhere: divide 2592000000 by 86400000 and the tier lands on 30, and the year tier likewise lands on 365, so "one month" on this page always equals exactly 30 days. The readings taken right at each boundary:
| diff (milliseconds) | reading |
|---|---|
| 59999 / 60000 | 59 seconds ago / 1 minute ago |
| 86399999 / 86400000 | 23 hours ago / 1 day ago |
| 2591999999 / 2592000000 | 29 days ago / 1 month ago |
| 31535999999 / 31536000000 | 12 months ago / 1 year ago |
Running both timezone settings separately, the differences and the readings in the table come out identical word for word, and only the starting ts differs by 46800000 milliseconds. Past and future differ only in the suffix: the number comes from Math.abs, the direction from the sign of diff>=0, and diff=0 falls on "0 seconds ago". Nothing in this ladder ever asks the calendar anything, so every label is decided purely by how many milliseconds the timestamp sits away from the current one.
What the Readings Look Like Across Months and Leap Years
28 days (2419200000 milliseconds) reads "28 days ago", 29 days reads "29 days ago", and only at a full 30 days does "1 month ago" appear for the first time; even 31 days (2678400000) still reads as nothing more than "1 month ago". The leap-spanning 366 days and the plain-year 365 days both read "1 year ago". 13 blocks of 30 days (33696000000 milliseconds) have already fallen into the year tier, so the literal reading "13 months" is unreachable here. Adding months by real calendar rules belongs to a different page, the time add and subtract calculator. The practical consequence is quiet but broad: everything sitting between one month and two months collapses into one and the same bucket, so a note left before a long weekend and a note left at the turn of the month look indistinguishable at a glance.
Figure 1: the reading ladder across month and leap-year edges, where 30 days and 31 days share a single cell.
Digit Recognition Is Not Just 10 Versus 13
The input side (L306-L309) first runs parseInt(val,10), then only checks String(ts).length<=10 before multiplying by 1000: recognition walks by digit count, but the tiers are not the two most people expect. A 13-digit input like "1717200000123" is not multiplied at all, and its iso line comes out as "2024-06-01T00:00:00.123Z"; 11-digit and 12-digit inputs are likewise taken as milliseconds, landing on 1970-07-18 and 1975-06-11. Negative values are the awkward ones: the minus sign in "-1717200000" eats a slot in the String length, turning it into 11, so the same number lands on 1969-12-12T03:00:00.000Z, while the 9-digit "-171720000" correctly walks the seconds path instead. The dividing line is drawn by digit count, not by sign, which is why the same digits can read two different ways depending only on whether a minus sits in front.
There are exactly three early exits: an empty string hides the result area, a NaN pops the toast 「请输入有效的时间戳数字」, telling you the field wants a valid timestamp number, and an out-of-range value pops 「无效的时间戳」, a plain invalid timestamp notice; the last two only toast, and all six cells keep showing whatever the previous round left there. Everything else slips through without even a hint: "20240531" is taken as seconds, multiplied by 1000, and lands on 1970-08-23T06:22:11.000Z, a date in the summer of that year that nobody typed on purpose and that the page never questions.
What Fill-Now Throws Away Is Exactly the Seconds
fillNowDt (L323-L328) first shifts by getTimezoneOffset()*60000 and then slice(0,16). Take FIXED_NOW=1717214437500: under the UTC+8 setting the shifted value is the 24-character string "2024-06-01T12:00:37.500Z", cut down to 16 characters with 8 characters dropped, and feeding the cut result back yields 1717214400, which sits 37 seconds below the real second 1717214437; the UTC-5 setting cuts the same value down to "2024-05-31T23:00" and feeds back to 1717214400 as well. One shared date-field string, "2024-06-01T00:00", produces 1717171200 on one setting and 1717218000 on the other, a gap of 46800 seconds, namely 13 hours, while the page as a whole carries no timezone control anywhere (greping for select returns 0 hits).
Figure 2: how many characters survive before and after the cut, and the offset gap between the two settings.
Rounding Direction and the Round-Trip Gap
Math.floor rounds toward negative infinity: with FIXED_NOW=-500 the seconds cell reads "-1", with -1500 it reads "-2", and -1717200000500 reads "-1717200001", one lower than flooring the absolute value would give. Paste that long string back in and feed it once more: its length 11 makes it count as milliseconds again, and the page reads "54 years later". Round trips inside one tier are clean: "1717200000" pasted into the date field and fed back differs by 0, while the millisecond tail of "1717200000123" only enters the iso output and never the local field, so after a refill the tail zeroes out and the difference is 123.
Repeated Behaviour and What Was Not Measured
The timer stub showed that setInterval registers only 1 entry, with a 1000 millisecond period (L270); after NOW advances by 86400000, the very same ts=1717200000000 moves from "0 seconds ago" to "1 day ago", because this cell is read fresh every time and never cached. The real on-screen digit jumps and the render counts stayed unmeasured in this round, since the pure Node harness had no renderer attached to watch them.
Where the Copy and the Code Disagree
Judging the timestamp entry in config/tools.php (L102-L115) statement by statement, the 10 lines split into [match] 6, [partial] 2 and [conflict] 2. The two conflicts: the description (L111) advertises 「多时区选择」, a multi-timezone picker, yet greping for select returns 0 hits and the date field is nothing but type="datetime-local"; the intro (L112), together with the template label at L51, 「北京时间(Asia/Shanghai)」, suggests a fixed zone, but that cell only runs the getter of the local machine, and under the UTC-5 setting it measured "2024-05-31 19:00:00". The partial matches concern the summary phrase 「支持时区」 and the digit-count wording of the placeholder; the page itself reads nothing beyond the local Date. As for which day a date lands on once whole months are added to it, an older on-site article already covered that once, so I will not repeat it here.
Frequently Asked Questions
Q: Why does an entry from 31 days ago only read "1 month ago"?
The month divisor is hard-coded as 2592000000 milliseconds, that is 30 days, and everything beyond it is floored against the same constant.
Q: Should I convert 13-digit milliseconds by hand first?
No conversion is needed, but the page recognizes more than 13 digits: the rule is that anything up to 10 digits gets multiplied by 1000, so an 11-digit or 12-digit input is treated as milliseconds just the same.
Q: Are the numbers in the result area always from this round?
Not necessarily: with an invalid input the page only pops the notice, and the six cells still carry the readings from the previous round.