Skip to content

feat: accurate location to timezone API#1038

Merged
accius merged 14 commits into
accius:Stagingfrom
lbatalha:accurate-location-timezones
Jun 1, 2026
Merged

feat: accurate location to timezone API#1038
accius merged 14 commits into
accius:Stagingfrom
lbatalha:accurate-location-timezones

Conversation

@lbatalha
Copy link
Copy Markdown
Collaborator

@lbatalha lbatalha commented May 30, 2026

This PR adds a new API that serves as a unified way to obtain accurate timezone information on locations. It can receive either a lat/lon pair or a grid locator and returns the correct (but unionized) timezone.

To do this it uses the [node-geo-tz](https://github.com/evansiroky/node-geo-tz) library which makes use of the standard [timezone-boudary-builder](https://github.com/evansiroky/timezone-boundary-builder)[timezone-boudary-builder](https://github.com/evansiroky/timezone-boundary-builder) project data. It even handles Oceans and Terra Nullius correctly.

Since we only really care about accuracy for current or future timezones, I have selected to use the now data product which is smaller than the alternatives (it currently sits at ~14MB versus 24MB for the next smallest alternative)

The library also implements lazy-loading of geojson areas with in-memory caching to avoid I/O and having to fully load the full dataset on startup.

I have left the package.json to always use the latest version of this library as that is important to ensure continued accuracy over time (Usually tz data sees at most a couple of updates per year)

With this PR I have modified the DX - TARGET panel to make use of this new API instead of the lon/15 solar time approximation. I have also added a local storage key so the browser remembers the user's preference on showing either local or UTC time for this panel.
Currently the local time text shows the chosen unionized timezone instead of the previous (local) indicator, which is informational and aids in finding issues, but users that are not that into timezones might be confused by the indicated timezone not matching the exact location (in the past, non-unionized timezones were the standard, but that is currently not the case) - regardless the displayed time is guaranteed to be correct as the unionized timezones take DST and other details into account. Let me know if we should change this.

The solar Sunrise and Sunset logic remains the same as it is the nice approach for what it wants to achieve - even so I migrated the existing code from toLocaleString to Intl.DateTimeFormat.format() with undefined locale (it uses the browser locale) for consistency and to put us in a better place for i18n etc.. (Tho I suppose for HH:MM display it might not matter much)

I have also migrated the DE local time in the page Header to Intl

We could make use of this to set the timezone for the user station as well, but I decided not to modify this behavior as it seems adequate and also to keep the PR as targeted as possible - in the future we could take a look to see if its worth implementing considering the tradeoffs to the existing implementation

In terms of performance, the library itself is pretty fast, but after considering the needs of the main OHC instance I concluded the following:

  • I did not add a dedicated timeout as we are not doing network requests, simply memory or disk reads (we catch any errors)
  • I did not add caching of any sort - caching based on lat/long is foolish as that would have infinitesimal hit rates without trading off spatial accuracy; caching on grid squares is slightly better, but the hit rate would very likely be too small to justify the extra compute needed to compute the cache key from lat/lon requests. Additionally this API is very standalone, so if for some reason we require horizontal scaling it should be trivial to do so.

This new API should make it really easy to integrate any future features that require access to accurate time information (I am currently implementing the callsign lookup popup feature which will make good use of it if available)

This should fix #1034

Type of change

  • Bug fix
  • New feature
  • Performance improvement
  • Refactor / code cleanup
  • Documentation
  • Translation
  • Map layer plugin

How to test

  1. Launch OHC
  2. Click around the map or click spots and notice the DX - TARGET panel has accurate UTC and Local time
  3. Sunrise/Sunset for DE and DX should also be accurate and show as before
  4. Header LOCAL time should show as before
  5. Reload the app and verify that it retains the last DX - TARGET timezone mode (UTC or Local)

Checklist

  • App loads without console errors
  • Tested in Dark, Light, and Retro themes
  • Responsive at different screen sizes (desktop + mobile)
  • If touching server.js: caches have TTLs and size caps (we serve 2,000+ concurrent users)
  • If adding an API route: includes caching and error handling
  • If adding a panel: wired into Modern, Classic, and Dockable layouts
  • No hardcoded colors — uses CSS variables (var(--accent-cyan), etc.)
  • No .bak, .old, console.log debug lines, or test scripts included

Screenshots (if visual change)

Before:
image
After:
image

lbatalha added 4 commits May 30, 2026 17:39
…ookup

Replace the longitude/15 approximation for DX local time with accurate
IANA timezone lookup via the new geo-tz library and /api/geo-time
endpoint.

Changes:
- New server: geoTz.js wrapper, geo-time.js route (express pattern)
- DXLocalTime.jsx: uses Intl.DateTimeFormat with real IANA timezone,
  persists UTC/local preference to localStorage
- useTimeState: fetches DX timezone from API, switches to Intl.format()
- Modern/Classic layouts: integrate DXLocalTime with fetched timezone
- Add GEO_TZ_DATA_PATH to .env.example
- 16 language files: new dxTime.showLocal/showUtc i18n keys
@lbatalha lbatalha requested review from MichaelWheeley and accius May 30, 2026 20:27
@MichaelWheeley MichaelWheeley linked an issue May 30, 2026 that may be closed by this pull request
3 tasks
Comment thread src/lang/th.json Outdated
Comment thread src/lang/th.json Outdated
Comment thread package.json Outdated
@MichaelWheeley
Copy link
Copy Markdown
Collaborator

these api calls work great,

http://localhost:3001/api/geo-time?lat=32.91254&lon=-117.08409
{"timezone":"America/Los_Angeles","localTime":"15:27","utcTime":"22:27","grid":"DM12kv"}

http://localhost:3001/api/geo-time?grid=DM12kv
{"timezone":"America/Los_Angeles","localTime":"15:27","utcTime":"22:27","grid":"DM12kv"}

but this is what I'm seeing in the DX view, am I missing something? I think previously there was some way of clicking from one timezone view to another but now that is no longer an option, perhaps it is in the wrong mode?
image

@lbatalha
Copy link
Copy Markdown
Collaborator Author

but this is what I'm seeing in the DX view, am I missing something? I think previously there was some way of clicking from one timezone view to another but now that is no longer an option, perhaps it is in the wrong mode?

Weird, I must have borked it for the Dockable view, apparently its setup differently to Modern and Classic.

Ill fix it, test it out in Modern/Classic for now

@lbatalha
Copy link
Copy Markdown
Collaborator Author

You can click the arrow icon or the timezone text (UTC) itself to swap

@MichaelWheeley
Copy link
Copy Markdown
Collaborator

You can click the arrow icon or the timezone text (UTC) itself to swap

  • sorry missed that, now in Modern view

  • if I entered the lat/lon for Perth {lat: -31.1282, lon: 116.0011} into node-geo-tz then find generates Australia/Perth which is a valid time-zone identifier..... but the query
    http://localhost:3001/api/geo-time?lat=-31.1282&lon=116.0011
    pushes Manila
    {"timezone":"Asia/Manila","localTime":"07:35","utcTime":"23:35","grid":"OF88au"}
    Numerically the same but visually not satisfying, is it possible to return and display the time-zone provided by node-geo-tz ?

image
  • another observation, if the connection to the server fails then the time-zone displayed doesn't revert to UTC so it can end up with something like this,
image

@lbatalha
Copy link
Copy Markdown
Collaborator Author

lbatalha commented May 31, 2026

I have fixed the Dockable display mode.

As for the Manila vs Perth difference, you are not using the same data package.
We are using const { find } = require('geo-tz/now'); which is the Same since now package - you are testing using const { find } = require('geo-tz'); which is the Alike Since 1970 package. The 1970 one is 24MB vs 13 for the Now variant. The details and differences between them can be found here https://github.com/evansiroky/node-geo-tz#entry-points

In my PR body I talked a bit about this, because I thought some users might find some edge cases where they find the timezone name being displayed confusing, even if correct.

We could just start using the 1970 variant, or even the Complete one (28MB) and accept a bit extra load in exchange for a visually more pleasing UX?

As for the offline situation, nice catch! Re-adding the legacy code as a fallback would work to solve this, tho that would fail silently and OHC doesnt have a dedicated visual indicator for the user to know they lost connection to the server, so it might be a bit weird. (ofc on local deployments, the server doesnt really go down by itself)

Copy link
Copy Markdown
Owner

@accius accius left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three things to fix before this can land:

geo-tz needs to be pinned (^x.y.z) instead of *. Wildcard lets npm land us on a breaking major between dev and prod.

The DXLocalTime UTC toggle is a clickable <span>, which we're trying to move away from. Heads up since this just landed yesterday and isn't documented anywhere yet: any clickable thing should be a real <button> so it's keyboard-focusable and screen readers announce it as a control. A clickable <span> is invisible to keyboard navigation and not announced as interactive. Joshua just did the conversion for ContestPanel in #1037 closing the corresponding a11y issue #1030. For this one it'd be:

<button
  type="button"
  onClick={() => setIsLocal((prev) => !prev)}
  aria-pressed={isLocal}
  style={{ background: 'none', border: 'none', padding: 0, color: 'inherit', font: 'inherit', cursor: 'pointer' }}
>
  ({isLocal ? timezone : 'UTC'}) ⇄
</button>

The /api/geo-time fetch in useTimeState has no AbortController, so rapid DX target changes can resolve out of order. Standard signal/abort cleanup in the effect's return fixes it.

On Michael's Manila/Perth catch, +1 to bumping to the 1970 data package, the 10MB is worth not having every user in a country with a quirky tz overlap reporting it. For the stale-when-offline case I'd fall back to the lon/15 approximation client-side so the panel is at least always showing something approximately right.

@lbatalha
Copy link
Copy Markdown
Collaborator Author

lbatalha commented May 31, 2026

Thanks for the review, those accessibility details are probably worth adding to a section in CONTRIBUTING.md so its not easy to miss for future work. I'll change it now, and keep it in mind for future work.

I'll add the out-of-order control logic as well as the offline fallback client-side.

geo-tz choices

For the version pinning, could we only pin to ti Major.Minor? If you check the releases, they maintain 8.1 and then every revision is just updating TZ data. This way we would avoid major or minor changes but ensure we keep TZ up to date.

So something like ^8.1

I think we should commit to the extra 4MB to go with the All dataset, it catches a few extra issues you can test:

  • the border between Papua New Guinea and Indonesia, when you move into Indonesia the 1970 one shows Tokyo timezone all the way to the Island where Makassar is
  • Palau island also shows as tokyo
  • Malaysia shows Singapore timezone
  • Cambodia and Thailand show as Asia/Jakarta

These are just a few I caught in that area of the world in 5 minutes of poking around

File sizes for reference.

❯ du --all node_modules/geo-tz/data/
25M	node_modules/geo-tz/data/timezones-1970.geojson.geo.dat
868K	node_modules/geo-tz/data/timezones-1970.geojson.index.json
708K	node_modules/geo-tz/data/timezones-now.geojson.index.json
29M	node_modules/geo-tz/data/timezones.geojson.geo.dat
14M	node_modules/geo-tz/data/timezones-now.geojson.geo.dat
916K	node_modules/geo-tz/data/timezones.geojson.index.json
70M	node_modules/geo-tz/data/

I'm not very well versed in the node/js ecosystem but I think npm doesnt have the concept of subpackages like python does (would be something like geo-tz[all] to only get the data for the one subpackage you want), so if we want to reduce our release size by 40-50MB we'll have to add some hooks to the build process to remove these (for Docker in particular)

@lbatalha
Copy link
Copy Markdown
Collaborator Author

lbatalha commented May 31, 2026

Here are the new changes:

Changes

Reliability

  • The api call now implements an AbortController with an additional 2s timeout
    • The 2 second timeout catches a slow server or connection, so that the wrong time isnt shown for too long when switching targets - which could be confusing (also avoids TCP connections remaining open until their long timeouts to save on server resources)
    • The AbortController also ensures we only respond to the latest request so we dont have latency-induced out-of-order responses
  • When triggered we fallback to the old lon/15 method of ascertaining solar local time
    • We display an amber warning sign similar to the one when the weather request fails, and the tooltip changes to clarify that we are we are using an approximation
image

Accessibility

I have revamped the timezone indicator to follow accessibility guidelines:

  • Convert the into a
  • Use aria-label so a screen-reader tells the user that it is a button and what action will be performed by toggling it (the actual toggled state using aria-pressed is not relevant since the reader will read the time and timezone block, followed by "Show local time at DX location, button")
  • Maintain title property so we still have a mouse hover tooltip
  • Maintain style properties to ensure appearance consistency with other similar UI elements

Timezones

I have swapped the geo-tz package to use the all data package, in order to solve the issues I reported earlier.

Testing

For testing, I recommend manually lowering the AbortController timeout to 100-200ms or so and using dev tools to validate its working as intended.

We might also want to tweak the timeout to something lower, as this is a very quick API so if it takes longer than a reasonable period its likely something is going very wrong. (But too low would punish users on slow connections, like mobile operations. (Though personally if I was doing that I would be running OHC locally...)

Let me know what you think of the warning triangle icon, its position, etc..., especially compared to the weather error message.

You will also note that the tooltip (and accessibility text) displays the result of clicking the button, instead of the current state, which I think is a better user experience. Thoughts?

@lbatalha lbatalha requested a review from accius May 31, 2026 18:43
Comment thread src/lang/ca.json Outdated
Comment thread src/lang/ms.json Outdated
Comment thread src/lang/ms.json Outdated
Comment thread src/lang/nl.json Outdated
Comment thread src/lang/nl.json Outdated
Comment thread src/lang/pt.json Outdated
Comment thread src/lang/pt.json Outdated
Comment thread src/lang/ru.json Outdated
Comment thread src/lang/ru.json Outdated
Comment thread src/lang/sl.json Outdated
Comment thread src/lang/sl.json Outdated
Comment thread src/lang/th.json Outdated
Comment thread src/lang/th.json Outdated
Comment thread src/lang/zh.json Outdated
Comment thread src/lang/zh.json Outdated
Comment thread src/hooks/app/useTimeState.js Outdated
Comment thread src/hooks/app/useTimeState.js Outdated
Comment thread src/hooks/app/useTimeState.js Outdated
@MichaelWheeley
Copy link
Copy Markdown
Collaborator

  • Dockable mode working great

  • localized time-zones from revised database working great

  • clicking on the ocean the timezone Etc/GMT+1 seems like it should be Etc/GMT-1 (minus rather than plus)?

image actually the further west you go the more positive the number, again seems like it should be more negative? image
  • toggling to UTC mode, clicking anywhere in the world the time in UTC is the same as the time on the main clock at the top of the screen, e.g.
image wondering it might be more appropriate to display the local time in both toggles but for one show other show the TZ name and the other the UTC+x ? e.g.
05:57 (Australia/Adaleide) 
or
05:57 (UTC+9:30)

@lbatalha
Copy link
Copy Markdown
Collaborator Author

lbatalha commented May 31, 2026

Yeah the Minus/Plus sign is that weird tzdata standard I mentioned yesterday. I suppose I should add some code to flip it for display, because its what most people expect.

Hmmm the UTC display for the DX-Target seems kinda useless....maybe we should just remove the ability to toggle it completely? Perhaps we could always add the offset to the line as just a 2-3 character thing, alongside the timezone name?

Thoughts?

@MichaelWheeley
Copy link
Copy Markdown
Collaborator

Yeah the Minus/Plus sign is that weird tzdata standard I mentioned yesterday. I suppose I should add some code to flip it for display, because its what most people expect.

highly confusing as GMT+1 = UTC+1 (within a leap second), and if it's correct that that equals Etc/GMT-1 .. then argggh.
I think if you see Etc/GMT you should immediately convert to UTC and mask it.

Yeah the UTC display for the DX-Target seems kinda useless....maybe we should just remove the ability to toggle it completely? Perhaps we could always add the offset to the line as just a 2-3 character thing, alongside the timezone name?

you mean remove the toggle and on one line display the local time, the timezone name and the UTC conversion?
e.g. 05:57 (Australia/Adaleide, UTC+9:30)

@lbatalha
Copy link
Copy Markdown
Collaborator Author

I'll write a utility function to convert the Etc/GMT timezones to a simpler UTC offset, so like: Etc/GMT+1 -> UTC-1. That should be way less confusing.

As for the toggle, yeah remove the button feature completely and just display something like that, or even 05:57 Australia/Adelaide (+9:30).

Hmm thinking of other possible formats 🤔

@MichaelWheeley
Copy link
Copy Markdown
Collaborator

in terms of physical space available the longest TZ name that I found was
image

@lbatalha lbatalha force-pushed the accurate-location-timezones branch from fea4d22 to 2fc7d6d Compare May 31, 2026 21:11
@lbatalha
Copy link
Copy Markdown
Collaborator Author

😰 that is horrifying
I guess this is why we use unionized timezones now 😅

I'm leaning towards just letting the user infer the offset from the given time to their own time or to UTC, as they prefer, and only displaying the IANA zone name (or even adding the offset as a bonus to the hover tooltip?)

@lbatalha
Copy link
Copy Markdown
Collaborator Author

lbatalha commented Jun 1, 2026

I've added a reusable filtering function to convert tzdata Etc/GMT timezones to UTC with the correct ISO8601 signs.

When you select ocean areas or terra nullius you should now see the correct offset based on UTC. Straight Etc/GMT is also just converted to UTC

On the topic of getting rid of the toggle completely, should I go ahead with the change?

Copy link
Copy Markdown
Owner

@accius accius left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four of my earlier points are addressed:

  1. geo-tz is pinned at ^8.1 — good.
  2. The DXLocalTime toggle is a real <button> now with aria-label and the unstyled-button pattern.
  3. useTimeState has an AbortController plus a 2s timeout — that's actually nicer than the 5s I had in mind for a hung server.
  4. Bumped to geo-tz/all for the 1970 dataset, with a solar lon/15 fallback so the DX panel always shows something even when the server is unreachable. The Etc/GMT sign-flip handling in formatGmtUtc for the Manila/Perth ISO display is a clean addition.

One small non-blocker for a followup: the catch in useTimeState setDxTimezone(null) on every rejection including AbortError. When the user rapidly changes DX target, the cleanup's controller.abort() fires before the new effect runs, so the old fetch rejects with AbortError → state briefly nulls before the new value lands → visible UI flicker (timezone label flashes to fallback ⚠ marker). Standard fix is:

.catch((err) => {
  if (err.name === 'AbortError') return;
  setDxTimezone(null);
});

Not a blocker — happy to land this as-is and clean up later.

K0CJH

@accius accius merged commit 9c7edf9 into accius:Staging Jun 1, 2026
6 checks passed
@accius accius mentioned this pull request Jun 2, 2026
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] accurate timezone lookup for locations

3 participants