tz-at-point 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 oo-pibe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,337 @@
1
+ # tz-at-point
2
+
3
+ **The right timezone for every place on your list, worked out ahead of time.**
4
+
5
+ [![ci](https://github.com/oo-pibe/tz-at-point/actions/workflows/ci.yml/badge.svg)](https://github.com/oo-pibe/tz-at-point/actions/workflows/ci.yml)
6
+ [![npm](https://img.shields.io/npm/v/tz-at-point)](https://www.npmjs.com/package/tz-at-point)
7
+ [![install size](https://badgen.net/packagephobia/install/tz-at-point)](https://packagephobia.com/result?p=tz-at-point)
8
+ [![types: TypeScript](https://img.shields.io/npm/types/tz-at-point)](https://www.typescriptlang.org/)
9
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/oo-pibe/tz-at-point/blob/main/LICENSE)
10
+
11
+ If your app shows a time at a place, a kickoff at a stadium, an opening hour at a store, a pickup at a depot, it has to know which timezone that place is in. The usual tools either read tens of megabytes of boundary data from disk, which a serverless function doesn't have, or guess from a compact grid and get it wrong near borders, where the wrong answer is a whole hour.
12
+
13
+ tz-at-point takes your list of places once, works out each one's timezone precisely, and saves the answers in a small file that ships with your app. At runtime it looks the answer up; nothing is downloaded and no file is read. A place that isn't on your list still gets an answer, from the compact grid.
14
+
15
+ It's for JavaScript and TypeScript projects with a fixed set of locations. If you need to resolve arbitrary coordinates that users type in, [other tools](#prior-art) fit better.
16
+
17
+ ```ts
18
+ import { createLookup } from 'tz-at-point';
19
+ import table from './zones.json' with { type: 'json' };
20
+
21
+ const zoneAt = createLookup(table);
22
+
23
+ zoneAt(65.8481, 24.1466); // { zone: 'Europe/Helsinki', source: 'table' }
24
+ zoneAt(40.4168, -3.7038); // { zone: 'Europe/Madrid', source: 'raster' } not in the table
25
+ ```
26
+
27
+ **Status:** 1.0.0. The runtime API is stable and the table format is versioned; changes are recorded in the [changelog](https://github.com/oo-pibe/tz-at-point/blob/main/CHANGELOG.md).
28
+
29
+ [Why](#why) · [How it works](#how-it-works) · [Install](#install) · [Quick start](#quick-start) · [API](#api) · [CLI](#cli) · [AI coding agents](#use-with-ai-coding-agents) · [What it promises](#what-it-promises) · [Prior art](#prior-art) · [Keeping the table current](#keeping-the-table-current) · [Troubleshooting](#troubleshooting) · [FAQ](#faq) · [Data sources](#data-sources)
30
+
31
+ ## Why
32
+
33
+ There are two common ways to turn a coordinate into a timezone in JavaScript, and each has a catch.
34
+
35
+ **[geo-tz](https://github.com/evansiroky/node-geo-tz) is exact**, but it reads about 30MB of boundary polygons from disk, which a bundled function doesn't carry and an edge runtime can't read at all. **[@photostructure/tz-lookup](https://github.com/photostructure/tz-lookup) reads no files**, but it's approximate, and near a border the neighbour often keeps different clocks:
36
+
37
+ | Place | Coordinate | Raster says | Actually | Off by |
38
+ |---|---|---|---|---|
39
+ | Tornio, Finland | 65.8481, 24.1466 | Europe/Stockholm | Europe/Helsinki | 1 hour |
40
+ | Tabatinga, Brazil | -4.2527, -69.9381 | America/Eirunepe | America/Manaus | 1 hour |
41
+
42
+ <img src="https://raw.githubusercontent.com/oo-pibe/tz-at-point/main/docs/border.svg" alt="A coarse grid over the Sweden-Finland border. One cell straddles the border and is coloured Swedish, but Tornio, inside it, is in Finland. The grid answers Europe/Stockholm; the boundary polygons answer Europe/Helsinki; a 21:30 UTC kickoff is 23:30 on one side and 00:30 the next day on the other." width="920">
43
+
44
+ **Your venues, stores or depots are a fixed list.** Resolve them once, at build time, with the exact polygons. Then nothing heavy ships and nothing is read at runtime.
45
+
46
+ **If none of your points are near a border, you don't need this.** `npx tz-at-point check zones.json --raster` says, for your own data, how many the compact grid would get wrong; if that is zero, use the grid on its own.
47
+
48
+ <details>
49
+ <summary>The numbers behind that: error rates, sizes, and a comparison table</summary>
50
+
51
+ **[geo-tz](https://github.com/evansiroky/node-geo-tz) is exact.** It reads the real boundary polygons from about 30MB of data files on disk. A JavaScript bundler won't pull those into your bundle, so unless you ship the `data/` directory alongside it and point `GEO_TZ_DATA_PATH` at it, every lookup that needs them throws:
52
+
53
+ ```
54
+ Error: ENOENT: no such file or directory, open
55
+ '/var/task/node_modules/geo-tz/data/timezones-1970.geojson.geo.dat'
56
+ ```
57
+
58
+ On Lambda you can do exactly that, and people do: copy the directory into a layer and set the variable, at 74MB against the 250MB unzipped limit. On an edge runtime you can't, because there is no filesystem to point it at.
59
+
60
+ [Its own README](https://github.com/photostructure/tz-lookup) puts the disagreement with geo-tz at ~10% of likely-inhabited points, ~5% even after forgiving zones whose clocks match. Measured a different way, at points sampled uniformly by area on land outside Antarctica, it returns a zone with the wrong UTC offset for **3.0% of the world**, 3.4% of North America and 1.5% of Europe (reproduce with `node scripts/raster-disagreement.mjs 60000`; [the script](https://github.com/oo-pibe/tz-at-point/blob/main/scripts/raster-disagreement.mjs) says how it counts).
61
+
62
+ [tzf-wasm](#prior-art) sits between the two: simplified polygons in a 4MB wasm asset, exact except within about 110m of a border.
63
+
64
+ | | geo-tz | tz-lookup (raster) | hosted API | tz-at-point |
65
+ |---|---|---|---|---|
66
+ | Accuracy at your points | exact | ~5–10% wrong | exact | **exact** (it is geo-tz, at build time) |
67
+ | Adds to your bundle | 74MB `data/`, shipped beside it | 74KB | — | **77KB, or 3KB via `/core`** + ~50 bytes per point |
68
+ | Reads files at runtime | yes | no | no | **no** |
69
+ | Works on edge runtimes | no | yes | yes | **yes** |
70
+ | Answers any coordinate | yes | yes | yes | your points exactly, everything else via the raster |
71
+ | Cost per lookup | — | — | $5/1k after 10k free (Google) | — |
72
+
73
+ "Exact" means it agrees with the OpenStreetMap boundary data; every option in that table is measured against the same data. One case is not exact: a point so close to a border that its rounded key falls on the other side. `build` warns by name when that happens, and [What it promises](#what-it-promises) says what the lookup does about it.
74
+
75
+ What `check --raster` prints for the three points above:
76
+
77
+ ```console
78
+ $ npx tz-at-point check zones.json --raster
79
+ built with geo-tz 8.1.9
80
+ raster: 2 of 3 points disagree with the table, 2 by a different UTC offset
81
+ -4.2527,-69.9381: raster says America/Eirunepe, table says America/Manaus
82
+ 65.8481,24.1466: raster says Europe/Stockholm, table says Europe/Helsinki
83
+ ok: 3 points match the polygons
84
+ ```
85
+
86
+ </details>
87
+
88
+ ## How it works
89
+
90
+ <img src="https://raw.githubusercontent.com/oo-pibe/tz-at-point/main/docs/flow.svg" alt="Build time: points.csv plus tz-at-point build, using geo-tz polygons and 10m probes, produce zones.json, committed to your repo; geo-tz never ships. Runtime: a lat/lng is answered by an exact key (source: table), then the nearest entry within its radius (source: table-near), then the raster fallback (source: raster, approximate), with no file reads." width="920">
91
+
92
+ Build resolves each point with geo-tz, then probes the ground around it on a ~10m lattice to find how far that zone holds. Those two facts, the zone and that radius, are all the runtime needs.
93
+
94
+ ## Install
95
+
96
+ ```sh
97
+ npm install tz-at-point
98
+ npm install --save-dev geo-tz # only to build and check the table
99
+ ```
100
+
101
+ Node 20.19+ or 22.12+; CI runs the suite on 22 and 24 and the packed tarball on 20.19.0 (Node 20 has been end-of-life since April 2026). The published types work with TypeScript 5.0 and later.
102
+
103
+ ## Quick start
104
+
105
+ **1. List your points** in a `.csv` with `lat` and `lng` columns, or a `.json` array of `[lat, lng]` pairs or `{ lat, lng }` objects. Extra fields are ignored.
106
+
107
+ ```json
108
+ [[65.8481, 24.1466], { "name": "Wembley", "lat": 51.5561, "lng": -0.2794 }]
109
+ ```
110
+
111
+ **2. Build the table and commit it.**
112
+
113
+ ```sh
114
+ npx tz-at-point build points.json -o zones.json
115
+ ```
116
+
117
+ ```json
118
+ {
119
+ "v": 1,
120
+ "attribution": "Timezone boundaries from OpenStreetMap (https://www.openstreetmap.org/copyright), ODbL 1.0. Zone names from the IANA tz database, public domain.",
121
+ "geoTz": "8.1.9",
122
+ "maxRadius": 250,
123
+ "points": {
124
+ "51.5561,-0.2794": ["Europe/London",250],
125
+ "65.8481,24.1466": ["Europe/Helsinki",250]
126
+ }
127
+ }
128
+ ```
129
+
130
+ Each entry is the zone and how far it holds, in metres. A point on a border gets 0, so it answers only its own rounding cell. The table also records the geo-tz version that produced it, so when boundaries change, the diff says so.
131
+
132
+ **3. Use it.** Import the table so your bundler embeds it, and create the lookup once, at module scope.
133
+
134
+ ```ts
135
+ const zoneAt = createLookup(table);
136
+
137
+ const { zone } = zoneAt(venue.lat, venue.lng);
138
+ const local = zone && new Intl.DateTimeFormat('en-GB', { timeZone: zone, timeStyle: 'short' })
139
+ .format(new Date(fixture.utcKickoff));
140
+ ```
141
+
142
+ **4. Keep it honest in CI.**
143
+
144
+ ```yaml
145
+ - run: npx tz-at-point build points.json -o zones.json --check # someone added a point but didn't resolve it
146
+ - run: npx tz-at-point check zones.json # boundaries moved, or the table was edited
147
+ ```
148
+
149
+ ## API
150
+
151
+ `createLookup(table, options?)` returns `(lat, lng) => Result`.
152
+
153
+ ```ts
154
+ type Result =
155
+ | { zone: string; source: 'table' | 'table-near' | 'raster' }
156
+ | { zone: null; source: null };
157
+ ```
158
+
159
+ - `table` is an exact key hit; `table-near` is inside an entry's safe radius and just as correct; `raster` means the fallback answered, so it's approximate; `null` means the input wasn't a coordinate, or nothing answered.
160
+ - The returned function **never throws**, whatever you pass it. A malformed table throws a `TypeError` from `createLookup` instead, when your function starts rather than mid-request.
161
+ - `options.fallback` swaps the raster for your own, or `null` turns it off. Importing from `tz-at-point/core` leaves the raster out of your bundle entirely.
162
+ - `pointKey(lat, lng)` gives the table key for a coordinate.
163
+
164
+ A complete handler, with its table, CI step and audit: [examples/serverless-function](https://github.com/oo-pibe/tz-at-point/tree/main/examples/serverless-function).
165
+
166
+ Full detail: [API reference](https://github.com/oo-pibe/tz-at-point/blob/main/skills/tz-at-point/references/api.md) · [CLI reference](https://github.com/oo-pibe/tz-at-point/blob/main/skills/tz-at-point/references/cli.md) · [setup guide](https://github.com/oo-pibe/tz-at-point/blob/main/skills/tz-at-point/references/setup.md).
167
+
168
+ ## CLI
169
+
170
+ ```sh
171
+ tz-at-point build <points.json|points.csv> -o <zones.json> [--check | --refresh] [--max-radius 250]
172
+ tz-at-point check <zones.json> [--raster]
173
+ tz-at-point --version
174
+ ```
175
+
176
+ `build` only adds points, never removes them, and leaves the file alone when there's nothing to add. `--check` reports missing points without writing, and without needing geo-tz. `--refresh` re-resolves every entry after a geo-tz upgrade. `check` re-probes the table against the boundaries you have installed, and `check --raster` also reports what the raster alone would answer for your points. Parallel builds of the same table are safe.
177
+
178
+ Exit codes: `0` success, `1` a check found a problem, `2` bad arguments or input.
179
+
180
+ ## Use with AI coding agents
181
+
182
+ tz-at-point ships an agent skill, [skills/tz-at-point/SKILL.md](https://github.com/oo-pibe/tz-at-point/blob/main/skills/tz-at-point/SKILL.md), that teaches a coding agent the whole workflow: building the table, wiring up the lookup, CI, and what each warning means. It uses the open Agent Skills format, so one folder works across tools.
183
+
184
+ | Tool | Install | Checked |
185
+ |---|---|---|
186
+ | Claude Code | `/plugin marketplace add oo-pibe/tz-at-point`, then `/plugin install tz-at-point@tz-at-point` | Installed, and it loads itself when a task calls for it |
187
+ | Codex | `codex plugin marketplace add oo-pibe/tz-at-point`, then `codex plugin add tz-at-point@tz-at-point` | Installed; the skill reaches the model's prompt |
188
+ | Kimi Code | `/plugins install https://github.com/oo-pibe/tz-at-point`, then `/reload` | Manifest follows Kimi's documented format; not run end to end |
189
+ | Claude.ai | Zip the `skills/tz-at-point` folder and upload it in your skills settings | Same format; not uploaded |
190
+ | Anything else | `npx skills add oo-pibe/tz-at-point`, or copy `skills/tz-at-point` into `.agents/skills/` | Codex reads `.agents/skills/` |
191
+
192
+ The skill is inside the npm package too, at `node_modules/tz-at-point/skills/tz-at-point/`. For agents that read `AGENTS.md` instead, point them at it:
193
+
194
+ ```md
195
+ ## Timezones
196
+ This project resolves timezones with tz-at-point. Before changing zones.json or any timezone lookup,
197
+ read node_modules/tz-at-point/skills/tz-at-point/SKILL.md.
198
+ ```
199
+
200
+ ## What it promises
201
+
202
+ A `table` or `table-near` answer agrees with the boundary polygons, with two documented exceptions: inside a key's ~11m rounding cell the key's zone wins, and a piece of another zone smaller than the probe lattice can resolve (about 7m) can hide inside a radius. Every stored radius keeps a fully probed ring beyond it, so a compact region bigger than that is caught, but this is sampling rather than proof: a sliver narrower than the 10m ring spacing can hide between two rings, and one a few metres wide that happens to run between probes on ring after ring can thread them however long it is.
203
+
204
+ `check` re-runs those probes against your installed geo-tz, so it catches stale entries, hand edits and moved boundaries. It can't catch what the probes were too coarse to see in the first place, and it is only as honest as the geo-tz the job installed: it uses the same one `build` did. Pin geo-tz through your lockfile, and don't let a pull request set the job's environment (`GEO_TZ_DATA_PATH` chooses the dataset).
205
+
206
+ Measured on 2026-09-23:
207
+
208
+ | | |
209
+ |---|---|
210
+ | Lookup | 380-410ns from the table, 550ns and up through the raster |
211
+ | Startup | 55-85ms and ~17MB for 30,000 points spread worldwide |
212
+ | Build | 2,218 geo-tz probes around each point at the default radius, 8,357 at 500, plus one for the point itself |
213
+ | Runtime dependencies | one, the raster; `tz-at-point/core` keeps it out of your bundle |
214
+ | Tests | 139, including bundled runs with file reads denied and a lookup checked against a brute-force scan |
215
+
216
+ Timings are from [`scripts/bench.mjs`](https://github.com/oo-pibe/tz-at-point/blob/main/scripts/bench.mjs) on one quiet machine under Node 25; one reviewer's runs on a loaded machine came out two to three times slower, and startup moves with how your points are spread. Run it on yours (`node --expose-gc scripts/bench.mjs`) rather than trusting mine.
217
+
218
+ During development the radius prober was also fuzzed differentially against geo-tz, and the suite was checked with mutation testing. Neither runs in CI. The fuzzer's one real find was a lobe of another zone dipping a metre or so inside a stored 130m radius, between the rings the prober sampled; [`test/radius.test.ts`](https://github.com/oo-pibe/tz-at-point/blob/main/test/radius.test.ts) keeps that case.
219
+
220
+ ## Prior art
221
+
222
+ Everything else in this space answers "any point on Earth, at runtime", and carries a global dataset to do it. Three of them:
223
+
224
+ - **[tzf-wasm](https://github.com/ringsaturn/tzf-wasm)**, the wasm build of the Rust port of **[tzf](https://github.com/ringsaturn/tzf)** (Go). It carries simplified boundary polygons in a separate 4MB `.wasm` asset, loaded by `fetch` with no filesystem, and answers any coordinate. Its bundled dataset is the simplified one: [its own accuracy notes](https://github.com/ringsaturn/tzf#accuracy) put boundaries within about 110m of the full-precision border, which is exactly the strip where this package spends its effort. On an edge runtime that needs arbitrary coordinates it is the better fit; for a fixed list of points near borders, it is not exact and this is.
225
+ - **geo-tz** itself, if you can ship its `data/` directory and set `GEO_TZ_DATA_PATH`.
226
+ - **[@photostructure/tz-lookup](https://github.com/photostructure/tz-lookup)**, the maintained raster, which this package uses as its fallback.
227
+
228
+ People have asked geo-tz for a smaller dataset for years: in 2018 its maintainer reopened [an issue about shipping a subset](https://github.com/evansiroky/node-geo-tz/issues/75) to say "that'd make a good feature… I'm open to receiving a PR", and closed it two and a half months later with a commit that added in-memory caching of lookup areas rather than a smaller download. [A Lambda user asking the same in 2024](https://github.com/evansiroky/node-geo-tz/issues/170) has had no reply from the maintainer. Narrowing the *dataset* is what people ask for; resolving a *known point set* instead is the answer I could not find packaged anywhere, so people write the same script by hand: resolve the points with geo-tz in `scripts/`, commit the JSON, keep geo-tz out of `src/`.
229
+
230
+ This is that script, made reliable: probed radii so nearby coordinates still resolve, a `check` command for CI, and the geo-tz version recorded in the table.
231
+
232
+ ## Keeping the table current
233
+
234
+ Timezone boundaries ship a few times a year (five releases so far in 2026), and occasionally a zone changes: `America/Coyhaique` was carved out of `America/Santiago` in 2025b, `Asia/Choibalsan` became an alias for `Asia/Ulaanbaatar` in 2024b. A committed table can go stale, so it says what produced it:
235
+
236
+ ```json
237
+ { "v": 1, "attribution": "…OpenStreetMap…ODbL 1.0…", "geoTz": "8.1.9", "maxRadius": 250, "points": { … } }
238
+ ```
239
+
240
+ `check` re-probes every entry against the geo-tz you have installed and prints both versions, so a data bump becomes a failing CI step and a readable diff, not a silent change of answer.
241
+
242
+ ## Troubleshooting
243
+
244
+ ### A lookup returns `{ zone: null, source: null }`
245
+
246
+ The input isn't a pair of finite numbers in range (strings like `'51.5'` don't count), or you're using `tz-at-point/core` or `fallback: null` and the point isn't in the table.
247
+
248
+ ### A known point comes back with `source: 'raster'`
249
+
250
+ The committed table is missing it. Run `npx tz-at-point build <points> -o zones.json` and commit. `build --check` in CI stops it happening again.
251
+
252
+ ### `build` warns that a key is within 10m of another zone
253
+
254
+ That point sits on a border, so its entry has a radius of 0 and its whole ~11m cell answers one zone. Nothing to fix unless the point belongs on the other side.
255
+
256
+ ### `check` fails with `table says A, polygons say B`
257
+
258
+ The table was edited by hand, or geo-tz changed. Run `build --refresh` with the same `--max-radius`, then `check`, and read the diff before committing.
259
+
260
+ ### Zone names changed after moving from geo-tz
261
+
262
+ geo-tz's default dataset merges zones that have kept the same clocks since 1970; tz-at-point uses the finer `geo-tz/all`. Baarle-Nassau comes back as `Europe/Amsterdam` rather than `Europe/Brussels`, with identical local times.
263
+
264
+ ### TypeScript rejects `import table from './zones.json'`
265
+
266
+ Under `module: nodenext`, add `with { type: 'json' }` (TypeScript 5.3+) and `"resolveJsonModule": true`. Bundler setups accept the plain import.
267
+
268
+ ### The function still fails with ENOENT after switching to tz-at-point
269
+
270
+ Something else reads a file at runtime, usually a data file loaded with `readFileSync`. Import it as JSON as well.
271
+
272
+ ### esbuild says `Could not resolve "@photostructure/tz-lookup"`
273
+
274
+ Only with `--platform=neutral`, which ignores `main` fields, and the raster declares nothing else. Neither wrangler nor Vercel's edge bundler uses that platform. If you set it yourself, add `--main-fields=module,main`, or import `tz-at-point/core`, which has no raster to resolve.
275
+
276
+ ## FAQ
277
+
278
+ **Does this replace geo-tz?** No. It uses geo-tz at build time, where reading 30MB of polygons is a one-off, and keeps it out of your deployment.
279
+
280
+ **How big does the table get?** Roughly 50 bytes per point (45 for short zone names like `Europe/London`, 65 for `America/Argentina/Buenos_Aires`), so 1,000 venues is about 50KB of JSON.
281
+
282
+ **What about daylight saving?** tz-at-point gives you the zone. The offset at a given moment comes from your runtime's own timezone database through `Intl`, so DST rules stay current without touching the table.
283
+
284
+ **Does it work in the browser?** The runtime does. Building the table needs Node and geo-tz.
285
+
286
+ **What if a place moves, or I delete one?** `build` adds and never removes. To prune, delete `zones.json` and build it again.
287
+
288
+ ## Data sources
289
+
290
+ tz-at-point's code is MIT. The answers come from three upstreams on different terms, and only one of them travels in your bundle.
291
+
292
+ | What | From | Licence |
293
+ |---|---|---|
294
+ | Zone names (`Europe/Madrid`) | [IANA time zone database](https://www.iana.org/time-zones) | [Public domain](https://github.com/eggert/tz/blob/main/LICENSE) |
295
+ | Boundaries, at build time | [geo-tz](https://github.com/evansiroky/node-geo-tz) ← [timezone-boundary-builder](https://github.com/evansiroky/timezone-boundary-builder) ← OpenStreetMap | Code MIT, data [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) |
296
+ | Raster fallback, at runtime | [@photostructure/tz-lookup](https://github.com/photostructure/tz-lookup) | CC0-1.0, built from the same OpenStreetMap boundaries |
297
+
298
+ ### What you install
299
+
300
+ | | Packages | Maintainer accounts | Size |
301
+ |---|---|---|---|
302
+ | Runtime (what your app installs) | 2, including this one | 1 | ~180KB |
303
+ | Build time, with geo-tz | 29 more | 63 more | 74MB |
304
+
305
+ Maintainer accounts are the distinct npm accounts that `npm view <package> maintainers` lists across each tree, counted on 2026-09-23. They move.
306
+
307
+ As of 2026-09-23, every dependency resolves from the npm registry with a verified signature and `npm audit` reports nothing. The only install script in the whole tree is esbuild's, a dev dependency whose script isn't needed: this repo's `.npmrc` sets `ignore-scripts=true`, and everything still builds and tests.
308
+
309
+ ### What that means for your table
310
+
311
+ `zones.json` holds your own coordinates, a zone name and a radius. It carries no OpenStreetMap geometry. The OSM Foundation's [Geocoding Guideline](https://osmfoundation.org/wiki/Licence/Community_Guidelines/Geocoding_-_Guideline) treats results like these as insubstantial extracts that don't trigger ODbL share-alike, and its [Attribution Guidelines](https://osmfoundation.org/wiki/Licence/Attribution_Guidelines) say a group of geocoding results "need not maintain attribution attached to the results, as long as it does not form a Derivative Database". Committing the table doesn't put your application code under ODbL, and the licence itself [doesn't reach software](https://opendatacommons.org/licenses/odbl/1-0/) ("This License does not apply to computer programs used in the making or operation of the Database").
312
+
313
+ Those guidelines are the Foundation's stated view rather than the licence text, and by its own [Legal FAQ](https://osmfoundation.org/wiki/Licence/Licence_and_Legal_FAQ) they "carry no formal legal weight". No court has ruled on where the line sits. If your table is large or central to a product, read them yourself.
314
+
315
+ Two things still apply.
316
+
317
+ **Credit OpenStreetMap in anything you ship.** The default import bundles the raster fallback, which is built from OpenStreetMap boundaries. One line wherever your app already lists third-party credits:
318
+
319
+ > Timezone data from [OpenStreetMap](https://www.openstreetmap.org/copyright), available under the [ODbL](https://opendatacommons.org/licenses/odbl/1-0/).
320
+
321
+ Importing from `tz-at-point/core` leaves the raster out, and then the table is all you ship. Every generated table carries this line in an `attribution` field, so the file explains itself wherever it ends up.
322
+
323
+ **Keep the table a list of your own places.** Resolving your venues is what this is for. Probing a lattice across a city or a country is what that guideline calls "systematically reverse engineering the whole or a substantial part of the OSM database through Geocoding", which would make your table a Derivative Database and put it under ODbL.
324
+
325
+ This is a summary, not legal advice.
326
+
327
+ ## Contributing
328
+
329
+ Bug reports are welcome, especially ones with a coordinate that gets the wrong answer. See [CONTRIBUTING.md](https://github.com/oo-pibe/tz-at-point/blob/main/CONTRIBUTING.md), and [AGENTS.md](https://github.com/oo-pibe/tz-at-point/blob/main/AGENTS.md) for how the code fits together.
330
+
331
+ ## Origin
332
+
333
+ I built this for [Road to Kickoff](https://roadtokickoff.com), a football trip planner that shows every kickoff in the stadium's local time. Its scheduled data refresh ran in a serverless function, where geo-tz couldn't find its data files, and about a fifth of fixtures came back with no timezone, the same fixtures every run. A missing zone means a kickoff shown at the wrong hour, with nothing on the page to say so. The fix was to resolve the grounds offline, commit the answers, and fall back to a raster for anything new. This package is that fix, extracted.
334
+
335
+ ## License
336
+
337
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};