Nepal is UTC+5:45, and it breaks timezone code
Nabin Shrestha · , 5 min read
I’ve had a meeting land at the wrong time because somewhere between the other person’s calendar and mine, the conversion went wrong.
It’s an easy mistake to make about Kathmandu. Nepal is five hours and forty-five minutes ahead of UTC, all year, with no daylight saving. It is the only country whose national time is on a :45 offset. There are two other places on :45 — New Zealand’s Chatham Islands (+12:45, or +13:45 in their summer) and a strip of south-east Western Australia that keeps +8:45 unofficially — and that’s the complete list among the 418 time zones my machine knows about.
So it’s a good test for timezone code, because most timezone code assumes offsets come in whole hours. India’s +5:30 catches some of those bugs. +5:45 catches the rest.
I found them while building the chart at the top of my home page: my working day in Kathmandu with yours lit up across it, so that a team lead in Berlin or New York can see how many hours we would actually share. I built it with an AI coding assistant, with :45 in the brief from the first line. Some of what follows I ran into as bugs; the rest was handled before it could bite. All five are places the whole-hour assumption hides.
1. “The offset in hours”
The first thing most code does with an offset is divide it by 60. For
Kathmandu that’s 5.75, and it goes wrong from there in predictable ways: Math.floor turns it into UTC+5, string formatting prints “UTC+5.75”, and a
slider with step="60" can’t land on Nepal at all.
The fix is to never leave minutes. Every offset in my code is a whole number of minutes — 345 for Kathmandu, −240 for New York in summer — and it only becomes hours at the last moment, when it’s written out for a person:
/** 345 → "UTC+5:45", -240 → "UTC−4:00". A true minus sign, not a hyphen. */
export function formatOffset(minutes: number): string {
const abs = Math.abs(minutes);
const h = Math.floor(abs / 60);
const m = abs % 60;
return `UTC${minutes < 0 ? '−' : '+'}${h}:${String(m).padStart(2, '0')}`;
} The slider under the chart runs from −12:00 to +14:00 in steps of 15 minutes. Offsets in use end in :00, :30 or :45, and 15 is the largest step that reaches all of them.
2. The built-in offset points the other way
JavaScript’s built-in answer, getTimezoneOffset(), has the sign reversed. In a process running on
Kathmandu time:
$ TZ=Asia/Kathmandu node -e "console.log(new Date().getTimezoneOffset())"
-345 Negative, for a zone ahead of UTC. It’s documented, and it’s still easy to get backwards. Worse for my purpose, it only knows the zone the code happens to be running in, which is no help when a reader in Berlin wants to know what time it is in Kathmandu.
So the offset for any zone is worked out from Intl instead. Format the
instant in that zone, read the wall-clock parts back as if they were UTC,
and take the difference:
const asIfUTC = Date.UTC(year, month - 1, day, hour, minute, second);
return Math.round((asIfUTC - at.getTime()) / 60000); Daylight saving then comes from the platform’s own timezone data, not from
a table I’d have to keep up to date. There’s one engine quirk inside it:
with hour12: false, some engines report midnight as hour "24", which has
to be read as 0.
3. Hour boxes overstate the overlap
The obvious way to draw a working day is 24 boxes, one per hour, with the shared ones lit. With whole-hour offsets that’s exact. With :45 it isn’t.
A Berlin working day in summer, 09:00–17:00 at UTC+2, is 12:45–20:45 in Kathmandu. Light every box that day touches and you light nine, 12:00 through 20:00. The real overlap is eight hours. The chart would overstate it by an hour, and to someone deciding whether we can work together, that hour isn’t a rounding error. It’s the answer to their question, and it’s wrong.
The boxes stayed, because they’re how you read the time of day at a glance. The shared time is drawn over them as bars placed to the exact minute.
4. Edges land mid-hour, and across midnight
Shift a 09:00–17:00 day by nine hours forty-five and it doesn’t stay inside
one day. A New York working day in summer runs from 18:45 to 02:45 the next
morning on Kathmandu’s clock. A window that is one { start, end } pair in
one zone is two pieces in another.
So nothing downstream works on single windows. Every window becomes a list of segments on a 0–1440 minute axis, split at midnight when it crosses it, with negative starts wrapped round — zone shifts produce those constantly:
export function toSegments(start: number, end: number): Segment[] {
const s = ((start % DAY) + DAY) % DAY;
let length = end - start;
if (length <= 0) length += DAY;
length = Math.min(length, DAY);
const finish = s + length;
if (finish <= DAY) return [{ start: s, end: finish }];
return [
{ start: s, end: DAY },
{ start: 0, end: finish - DAY }
];
} Intersections, totals and the longest shared stretch are all computed on those lists, never on a bare start and end.
5. Two bands that meet look like two answers
My day has a core band, 11:00–20:00, and an evening band, 20:00–23:00, which I keep open so that North American mornings can reach me. For a reader in London in summer, whose day is 13:45–21:45 in Kathmandu, the overlap is one unbroken run across the 20:00 seam. Computed band by band, it comes out as two pieces, and “the longest shared stretch” quietly becomes whichever piece is bigger.
So segments that touch are merged before anything is reported, and London is told 09:00–17:00 of their own day, which is the true answer.
This one isn’t specific to :45. But with whole-hour offsets I might never have seen it, because the seam would have fallen on a box edge and looked fine.
What it adds up to
For a New York reader in summer, we’re nine hours forty-five apart, with an hour and a quarter of overlap inside my core day and four and a quarter hours in total. For Berlin, three hours forty-five apart and eight hours shared. Those numbers are pinned in tests at a fixed instant, 31 July 2026, so daylight saving can’t make them flaky.
The tests also make the code’s two views of the problem check each other. The chart measures overlap by time zone; the slider measures it by raw offset; for every reference city, both must return the same number of minutes.
How to check it
The chart is on the home page of this site, and it opens on your own offset. Drag the slider to UTC+2:00 and it says 09:00–17:00 UTC+2:00, which is 12:45–20:45 in Kathmandu, then 7h 15m in my core hours, 45m in my evening: the Berlin numbers above, to the minute.
To see the offsets on your own machine:
node -e "for (const tz of ['Asia/Kathmandu','Pacific/Chatham','Australia/Eucla'])
for (const d of ['2026-01-15T12:00Z','2026-07-15T12:00Z']) {
const p = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false,
year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' })
.formatToParts(new Date(d)).filter(x => x.type !== 'literal').map(x => [x.type, x.value]));
console.log(tz, d.slice(5,7), (Date.UTC(+p.year, p.month-1, +p.day, p.hour==='24'?0:+p.hour, +p.minute, +p.second) - new Date(d)) / 60000);
}" That prints 345 for Kathmandu both times, 825 and 765 for Chatham, and 525 for Eucla.
The lesson is cheap to apply even if you never meet a :45 offset. Keep time in the smallest unit you’ll ever need, convert to something human only at the edge, and test with the awkward case rather than the convenient one. Kathmandu isn’t an edge case to the people who live here.