Date objects carry both CLR and JavaScript members
-
What you'll see
A date is off by exactly one month, in some code paths and not others. Filters miss records at a month boundary, a generated period label names the wrong month, or a comparison between two dates built in different parts of the file disagrees with itself.
Both spellings look correct in isolation, because both are real members of the same object:
let d = new Date(); d.Year; // 2026 (CLR) d.getFullYear(); // 2026 (JS) - agree d.Month; // 8 (CLR, 1-based) d.getMonth(); // 7 (JS, 0-based) - do NOT agree d.Day; // 20 (CLR) d.getDate(); // 20 (JS) - agreeOr a time is two hours early (one in winter), but only for dates that came back from JSON or an API response. The CLR member reads the zone the date was parsed with:
let d = new Date("2026-10-23T18:14:57Z"); // as saved since 23 October 2026 d.Hour; // 18 (CLR, the zone it was parsed with) d.getHours(); // 20 (JS, local time) -
What's actually happening
A
Datehere is a .NET (CLR) value exposed to HashJS, and it keeps both APIs. The CLR members (.Year,.Month,.Day) and the JavaScript members (.getFullYear(),.getMonth(),.getDate()) sit on the same instance, each carrying its own platform's convention unchanged.Those conventions differ in exactly one place. .NET numbers months from 1; JavaScript numbers them from 0. Everything else lines up, and that is the whole difficulty:
.Yearand.getFullYear()returning the same number is not evidence that the two APIs are interchangeable, but it reads like it. Code then picks whichever spelling came to hand, and a calculation that mixes them is wrong by one month in every month of the year.The failure is quiet because both values are plausible. An off-by-one month is not obviously garbage the way a
NaNor an empty render is — it is a real month, just the wrong one, and it only looks wrong to someone who knows what the answer should have been.The zone is the second difference. The CLR members are the parts of the value as it was parsed: a date read from
"…Z"has UTC parts. The JavaScript getters,toLocaleDateString,docly.formatand#value:format#convert to local time first, as JavaScript does. Fornew Date()and for text without a zone the two agree, because both are already local — which is why the difference only shows up on dates that came back from JSON. Since 23 October 2026 that is every date HashJS saves; see Dates are saved as UTC and shown in local time. -
What to do
1. Pick one API per expression and stay inside it. Mixing the two in a single calculation is off by one, always:
// consistent - CLR throughout let label = d.Year + "-" + d.Month; // consistent - JavaScript throughout let label = d.getFullYear() + "-" + (d.getMonth() + 1); // wrong - mixes the two let label = d.Year + "-" + d.getMonth();2. Use the JavaScript getters or
docly.formatfor anything a person will read. They show local time. The CLR members (.Hour,.Day,.Month) are the parts in the zone the date was parsed with — UTC for any date read back from JSON — so.Dayis yesterday's date for anything saved between midnight and 02:00. Use a CLR member only on a date you created locally withnew Date(), and writegetMonth() + 1rather than mixing in.Month.3. Reviewing code you did not write, check the month spellings first when a date is off by one. The bug is almost never in the arithmetic around it.
4. For formatting, do not assemble the string yourself.
docly.formathandles month names and ordering, and respects the culture — but a request starts with the invariant culture, so set one first. See A request starts with the invariant culture.5. When in doubt, print both. One throwaway page settles which member you are actually holding — see Use a scratch hash file to test Docly functions.
Last reviewed 23 Oct 2026