Easy-to-manage touch screens
Free, open-source touch screens. Guided USB setup, a drag-and-drop editor and local control. Made for Home Assistant.
Access, seasons, permits, fishing rules and migration data for Utah hunting and fishing.
LifestyleJavaScript · PythonGitHub @Axis174OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Ranger Hawk - personal Utah hunting reference: offline phone app, calendar reminders, and a daily data refresh for birds and big game.
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
Live at https://rangerhawk.com · calendar feed at https://rangerhawk.com/hunt.ics
_Formerly "Utah Hunt Atlas"; the repo was renamed ranger-hawk on 2026-10-01. The name goes back to RangerHawk, a hunting and fishing community site first built in 2016._Read the full README ↗Live at https://rangerhawk.com · calendar feed at https://rangerhawk.com/hunt.ics
Formerly "Utah Hunt Atlas"; the repo was renamed ranger-hawk on 2026-10-01. The name goes back to RangerHawk, a hunting and fishing community site first built in 2016.
A personal, installable phone app plus a daily data refresh for Utah hunting — birds (pheasant, chukar, duck, ptarmigan) and big game (deer and elk, archery through rifle) — and, since 2026-09-27, Utah fishing: the statewide limits, every water with rules of its own, and UDWR's emergency changes. Drive times are measured from the Utah towns you choose: twelve come with the app, and you pick up to three (see "Home towns" below).
Not affiliated with UDWR. Always confirm against the current guidebook and the Utah Hunt Planner before you hunt.
backend/ holds one Cloudflare Worker (worker.js), its config (wrangler.toml)
and a database schema (schema.sql). It does two things the phone cannot:
alert a contact by email when a hunter is past their back-by time and never
checked in (a cron every 10 minutes), and keep trail camera photos and tags in
cloud storage so they survive a lost phone and can sync between devices. Sign-in
is a 6-digit code by email; no passwords. Every row belongs to one user; there is
no sharing between users, on purpose.
What deploying it takes: a Cloudflare account (Pete has one), wrangler login,
one D1 database, one R2 bucket, an email sender (Resend, free tier 3,000 emails a
month, needs the rangerhawk.com domain verified), and api.rangerhawk.com as
the Worker's custom domain. SMS alerts would add Twilio (about $1 a month for a
number plus under a cent a text). Expected cost at Pete's own scale: $0. At a
few hundred hunters: R2 storage is about $0.015 per GB a month; a hunter with
2,000 photos at 20 KB is 40 MB, so cents each. The app has no code calling this
yet; that is the next step after deployment.
The mark was replaced on 2026-10-07 with the locked "hunting head": a streamlined
hawk head in profile with three trailing streaks and a slanted eye slit. The slit
is orange (#E98728) for the hunting side and blue (#1D95CA) for the water side;
one-colour versions exist for embroidery and seals. The source files and the full
export set (SVG and PNG, lockups, icons, signature strips, seals, variations, with
a README) are in ~/Documents/Hunt-2026/brand/logo-update/exports/, regenerated by
export.py beside it. The brand strategy is
~/Documents/Hunt-2026/brand/RANGER_HAWK_BRAND_STRATEGY.md. In this repo,
docs/icons/ holds the new tile (charcoal tile, white head, orange eye) as
icon-180, icon-192, icon-256, icon-512 and maskable-512, plus
favicon-32.png; rangerhawk-wordmark.svg is the one-line light lockup in the
Today header and on the website pages; sig-tile-180.png is used only by the email
signature, not the app. The favicon set is favicon.svg, favicon-16, -32 and
-48.png in docs/icons/ and a 16/32/48 docs/favicon.ico at the root (16 px is the
mark's floor and it reads soft there; that is the locked mark, not a fault).
icons/og-image.png (1200x630) is the picture a shared link shows: the stacked light
lockup and one line on the dark ground, generated as the og family by export.py
(python3 export.py --og-only writes only that family and touches no other export).
Where the mark goes on the site, decided 2026-10-09: the header is charcoal in both
colour schemes, so the light lockup is the only one used and theme-color stays
#2C2C2C; the lockup appears on Today (which serves both sides) and on the website
pages, so there is no hunt/water swap by tab. No seals and no pattern on the site
until the directory exists; the Maps layered head is held (strategy section 11).
Colours are charcoal #2C2C2C, orange #E98728 and brand blue #1D95CA. Links and
buttons use a darker blue, #15709A, because the brand blue is too light to read as
small text on white. The brand type is Zilla Slab for headings and the wordmark,
Public Sans for reading text and IBM Plex Mono for data; the app loads all three
from docs/fonts/ (docs/styles.css). The 2016 site files stay in
~/Documents/Hunt-2026/rangerhawk-2016/ for history.
Use GitHub's own transfer (repo Settings → Danger Zone → Transfer ownership). It keeps the history, and the old address redirects.
The app answers at the custom domain rangerhawk.com (DNS at Cloudflare, two
DNS-only CNAME records to <account>.github.io). After a transfer, set the custom
domain on the repo's Pages settings again if it did not carry over, and repoint both
CNAME records at the new account; the public address, the home screen icon and the
calendar subscription all stay the same.
The calendar's event ids end in @utah-hunt-atlas, the repo's first name. Leave them:
an id is only an identifier, nobody sees it, and changing it could duplicate events in
calendars that imported the feed.
1. Turn on GitHub Pages. Repo Settings → Pages → Build and deployment →
Source: GitHub Actions. The workflow deploys docs/ on every run.
2. Install the app on your phone. Open the Pages URL in Safari (it must be Safari — Chrome on iOS cannot install a PWA), then Share → Add to Home Screen. It gets its own icon and works offline from then on.
3. Subscribe to the calendar. In the app, Reminders → Subscribe in
Calendar. Or by hand: iPhone Settings → Apps → Calendar → Calendar Accounts →
Add Account → Other → Add Subscribed Calendar, and paste
https://<your-pages-url>/hunt.ics.
That is the whole install. Reminders arrive through the normal calendar, so they work whether or not you ever open the app.
Today — the next deadline as a countdown, legal shooting light for the day, what is open right now, the five closest places to the town selected in the header, the Great Salt Lake level, and what is coming. Where am I reads the phone's GPS and names the big game hunt boundaries you are standing in plus the closest access points; it works with no signal once the app has been opened online. It asks for the GPS fix first and shows the unit names while the hunt lists load; the open result fills in the unit links when those lists arrive.
Access — all 107 bird access points, searchable, sorted by real road drive time from the selected town (88 have a routed time; the other 19 show straight-line miles, marked as miles). Each one opens to season dates, restrictions, required permits, contact, source and confidence, plus a National Weather Service forecast for that spot when there is a signal.
Map — all of Utah, offline. Roads, dirt two-tracks (brown dashes), trails (brown dots), water and place names, with DWR properties in green, Walk-In Access in amber, big game hunt unit lines in purple and the access points as dots you can tap. Tap anywhere for the property and hunt units at that spot. The GPS button follows you with no signal. Save map for offline stores the 65 MB map on the phone once; after that the map never needs a connection.
Find a hunt (Seasons tab, and a button on Today) — type "I want to hunt elk
near home with a rifle" and get the hunts that exist there this year: the app
reads species, weapon, place and points from the sentence, finds the hunt
boundaries under the place with the unit shapes it carries, matches them to
UDWR's 2026 hunt-type unit lists (docs/data/ut/hunt_units_2026.json, from UDWR's
ArcGIS services) and to the draw results, and asks a question when something is
missing. Sub-unit hunts are marked as maybes. No signal needed; no AI service -
a keyword reader, so it says exactly why it answered what it did.
Unit sheet — open a unit from a map popup, Where am I, a finder row or
?unit=<slug>. One sheet per polygon shows each hunt list's boundary description,
the species and list types, the unit's general seasons or guidebook hunts and dates,
and the resident and nonresident draw figures. It never works out a merged season.
Trip plan (Today) — where you are going, back-by time, contact, vehicle, party; sent as an ordinary text from the phone; pin the truck; bearing and distance back to it; a Search-and-Rescue read-out of the exact spot; a red overdue warning with a one-tap "I'm OK" text. Stays on the phone. It cannot alert anyone with no signal - that needs the server described under "Backend", and the plan text tells the contact what to do meanwhile.
Landowner tags (Contacts) — a 16-state plain-language guide to landowner
permit programs and whether a tag may lawfully be sold to a hunter
(docs/data/ut/landowner_tags.json, researched 2026-09-21 from statutes, rules and
agency pages, with a confidence level per state). Utah note: the vouchers that
may be sold are Landowner Association vouchers under R657-43; mitigation permits
under R657-44 are not sellable; CWMU vouchers are designated, not sold.
Cams — a trail camera log that lives entirely on the phone (docs/cams.js).
Make a camera site, bring in the photos from its SD card, and the app reads the
time the camera stamped on each one, keeps a 640 px copy in the browser's own
database (about 20 KB a photo), flags black and featureless frames, and sorts
the rest with the most likely animals first by comparing each night frame with
the site's typical empty frame. Tag with one tap (elk bull, elk cow, deer buck,
and so on); tagging moves to the next photo. Patterns turns tagged photos
into visits (frames under five minutes apart count once) and charts them by hour
against legal shooting light for that date, with moon phase and a plain read-out
("20% of visits were in legal light - this is mostly a night spot"). Each site
carries its state and land type, and the screen shows that state's trail camera
rule from docs/data/ut/cam_rules.json, in red when a seasonal ban is in force
today. Nothing is uploaded; there is no account and no backup. Tested on 130
real Stealth Cam photos from 2018: 0.3 s a photo on a laptop, 30 flagged blank.
Known weakness of the sort: frames of the camera being handled score highest,
and an animal at the dark edge of the flash scores low, so nothing is ever
hidden or deleted automatically.
Fish — type "trout near home" or the name of a water. The screen has four parts. Near: 435 places from UDWR's own layers, by road time from the selected town, each with a chip saying what the app found for it today. By water: all 179 entries of the guidebook's "Rules for specific waters" and the 64 community fishing waters. Statewide: the daily-limits table, 39 general rules and the general rules for spearfishing. In force: the statewide kokanee closure and every emergency change, with the days each has left. Opening a water shows, from the top down: anything wrong with the rules as a whole today, the kokanee closure while it runs, any emergency change, the water's own rules stretch by stretch, the rules of a community water, the whole statewide table with the rows a rule above speaks to marked, the rules for groups of waters that may also cover it, and what the guidebook's spearfishing section says about it. Everything is in UDWR's words. Works with no signal. See "Fishing" below for how it is kept honest.
Seasons — every season for birds, deer, elk and turkey with live open/closed state. A Draw odds switch shows UDWR's published draw results: pick limited entry, general deer or antlerless, a species, resident or nonresident, and your points; every hunt lists what happened last year to people with your points, best first. Tap a hunt for the full point table (bonus and random permits shown separately), the same points a year earlier, and the level at which everyone drew. Points you enter stay on the phone.
Reminders — calendar subscription, every deadline, and the permit checklist.
Contacts — the DWR offices with open questions attached, the Walk-In Access landowners who must be phoned before you set foot on the property, and Utah's written-permission law spelled out. Where UDWR asks you to phone the owner first, the app says so in UDWR's own words and points you at UDWR's Walk-In Access map for the number. It does not carry landowners' personal numbers: this repository is public, and they did not agree to be in it.
Home towns (Menu) — twelve Utah towns come with the app, each shown by a short
code: Salt Lake City (SLC), Ogden (OGD), Provo (PRV), Logan (LGN), Heber City (HC),
Vernal (VRN), Price (PRC), Richfield (RCH), Torrey (TOR), Cedar City (CDC), St. George
(STG) and Moab (MOB). On the Home towns page you pick up to three, in the order you
tap them; the first is home. Under each chosen town there is one box for your own
words for it, comma separated, and the finders then treat those words, and the word
"home", as that town: "elk near home" means the first town, and a word you give another
town means that one. The header shows the code of each chosen town, one chip for one
town; the pressed chip is the town drive times are measured from, and tapping another
chip changes the minutes on every screen without changing the order of your towns.
A sentence the Fish screen or the hunt finder already read ("trout near home") is read
again when you take a town off or change a word, so "home" is the first town you have
now; a town taken off and chosen again on the same page gets its words back, and
closing the page forgets them.
The towns you choose and your words for them stay on the phone, in its own storage
(ha.anchors and ha.anchor); they are not in any data file and nothing is sent. A
hunter who never opens the page gets Salt Lake City and the app works. The names of the
towns are not finder words: Provo, Price, Logan and Ogden are also river names, so
"provo river" is a water, not a town. A phone that had tapped a home chip under v35
(ha.home) is carried over once to Salt Lake City, Heber City and Torrey, with its old
choice still measuring; words are not carried, so they are typed again once.
Refreshed daily at 5:15 a.m. Mountain by .github/workflows/refresh.yml.
Everything lands in docs/data/ut/.
| File | What it is |
|---|---|
bird_access.json | 107 access points; 88 carry road-routed drive times from all twelve anchor towns, the other 19 have none |
seasons.json | Season dates and deadlines. Hand-maintained from the guidebooks |
hunt_units_2026.json | UDWR's hunt-type unit lists, with polygon ids and verbatim boundary descriptions. Built nightly by scraper/build_hunt_units.py inside refresh.py; config.json selects hunt_year |
hunt_table_2026.json | Hunt numbers, headings and printed season spans from the Big Game Application Guidebook. Built by hand with scraper/build_hunt_table.py after the PDF-reissue flag and a reviewed diff |
fishing_rules.json | The fishing guidebook, in UDWR's words, with emergency changes. Built by scraper/build_fishing.py, never edited by hand |
fishing_places.json | 435 places to fish, with the rules each is linked to. Built by scraper/build_fishing_places.py |
fishing_notices.json | UDWR's list of emergency changes to the fishing guidebook, read daily |
config.json | The twelve anchor towns, contacts, landowner calls, permits, trespass law |
access_contacts.json | Properties where UDWR asks you to phone the owner first, in UDWR's wording with the owner's name and number removed. Built by scraper/build_access_contacts.py from the two property layers. |
raw_*.json | Verbatim upstream pulls, kept so diffs are auditable |
community.json | UDWR news and Reddit, filtered for relevance |
changelog.json | Last 60 runs: what changed, field by field |
The hunt-type lists are a separate nightly ArcGIS pull. The producer discovers
year-prefixed big-game services, joins names to polygon ids by exact spelling
and the reviewed alias table, and preserves UDWR's boundary descriptions.
A failed pull, unresolved name, slug collision or list losing more than a third
of its rows leaves the committed file unchanged and adds a named changelog flag.
An unchanged pull preserves the file and its last-change pull date byte for byte.
When next year's lists appear the job flags them and keeps the current file;
the lead selects the year by editing config.json's hunt_year.
The hunt-number table is parsed by hand-run python3 scraper/build_hunt_table.py
and committed after a diff; the nightly never writes it or infers dates for
multiseason or Dedicated Hunter rows whose guidebook entries give no span.
node scraper/test_units.js checks the real unit renderer, finder rows, deep links,
GPS and map entry points and edition dates under six clocks; it runs in the nightly.
Its list, row and polygon counts and its named-unit checks are pinned on purpose: a
UDWR list change stops the nightly until a human has read the change and re-pinned.
config.json lists the twelve anchor towns under anchors: an id, a label, the
header code, the county and a town-centre point (lat, lon). They are public
places on purpose, with no one's home among them; the towns a hunter picks, and the
hunter's own words for them, are kept on the phone only (see "Home towns" above). The
points are town centres, not street addresses, and the ids heber and torrey keep
the points the app used before v36, so their minutes match v35 except where OSRM's road
data has changed since: four fishing places moved by 3 to 36 minutes (Annabella Lake,
Deep Lake, McGath Lake and Lower San Rafael River WMA), and every other fishing place
and bird point is within 2 minutes.
A drive time is baked from every anchor to every place, by road, from OSRM's public
router. Fishing places carry d: {<anchor id>: {min, mi}}; bird access points carry
drive: {<anchor id>: {min, mi, reliable, range}}. Minutes are whole numbers and miles
are road miles to a tenth. The phone reads the minutes (and the range) for whichever
anchor is selected in the header. The 19 bird points with no routed time keep an empty
drive, and the phone shows straight-line miles for them. Both bakes are run by hand
when the anchor list changes or a place moves; neither is in the daily job:
python3 scraper/build_fishing_places.py --drive-only # fishing drive times only, in the file as it stands: about five requests, no UDWR pull
python3 scraper/build_bird_drive.py # bird drive times, for the points that carry one (add --all for every point)
The full --drive run of build_fishing_places.py bakes the same twelve anchors
along with a fresh UDWR pull (see "Keeping it current"). Every run is polite to the
shared server: at most 100 coordinates a request, one request every 1.5 seconds, and
on a refusal it waits, halves the chunk and asks again. A place whose chunk is never
answered keeps its old minutes, anchor by anchor, and the run says so.
The bird range rule. Eight bird points are marked "centroid": true in
bird_access.json (Wallsburg WMA twice, Howard Slough WMA, Willard Spur WMA,
Farmington Bay WMA, Farmington Bay, Willard Bay Upland Game Area and Ogden Bay WMA).
Each is the middle of a property, not a place a car stops, so the road finishes down
dike roads and the routed time overstates the drive. For a centroid point and an
anchor, low is the time the same road miles take at 45 mph, round(miles * 60 / 45),
worked from OSRM's miles before they are rounded to a tenth. If the road time is at least
1.5 times low, the figure is reliable: false with range: [low, min], and the
phone prints "low-min min *"; otherwise it is reliable: true and range is null.
Every figure on every other point is reliable: true with no range. The flag is data,
so the rule survives a rebake. Today 21 of the 96 centroid figures are ranges.
The calendar uses three fixed anchors. hunt.ics is one file for every
subscriber, so it cannot follow a phone's choice. CALENDAR_ANCHORS in
scraper/build_calendar.py names Salt Lake City, Heber City and Torrey (slc,
heber, torrey); the "(near ...)" items in the fishing events are waters within 75
straight-line miles of one of those, and each event says so in those words. The run
stops with a plain message if one of the three ids is missing from anchors.
Not just "did the file change" — it diffs specific fields and reports them:
WIA PrimaryPurpose, SeasonalClosure, SpecialRestrict, Status; property
closureDates and restrictions; CHA Status and contact details; pheasant
release Location and Hunt. Guidebook PDFs are hashed, so a reissue is
flagged loudly — those need a human read, because season tables are the one
thing that must not be parsed by guesswork.
It also scans UDWR pages for the 2027 big game application window, which is
not yet published. The two application deadlines in seasons.json are marked
projected: true and carry the 2026 dates until the real ones appear.
Hunting is one guidebook and one table per species. Fishing is a statewide table, 179 entries that each override part of it, groups of waters that share a rule, river stretches with different rules on the same river, and signed emergency changes that UDWR posts during the year and never folds into the PDF. There were nine of those in 2026 by September. The guidebook states the order itself: "On waters that have a specific rule, that rule takes precedence over the general rules."
scraper/build_fishing.py lifts every rule
out of the PDF by position and keeps the wording and the page. The app shows
that wording. Beside each rule is a small index (fx) that only sorts it: what
it is about, which rows of the statewide table it takes over, which dates it
covers. The app works out yes-or-no answers from the index - closed today,
flies and lures only, catch and release, no motors - and never works out a
merged limit.scraper/fishing/overrides.json. A
pattern has no "any words here": where a rule gives dates the pattern takes
only the words dates are written in, and where it lists fish, only fish. So
"Limit 8 trout from Aug. 1-Oct. 31 (at all other times the limit is 2)" cannot
be sorted with its second half swallowed; it stops the build instead. Words
such as CLOSED, "limit", "must be released" and "artificial flies only" cannot
appear in a rule without the index saying so, whoever sorted it, and a date
written in by hand must be a date the same stretch prints. On the phone, an index
entry the app does not fully understand is shown as "not sorted - read it". A
place that cannot be matched to a guidebook entry says so, shows the entries
with a similar name, the group rules for its county, and every water with its
own rules in that county. None of these is ever read as "statewide applies".When UDWR posts an emergency change (the daily run prints a NOTICE UNREAD:
line in the job log and lists it under unread_notices in the changelog's run
entry, and the app shows it as unread; the rest of the refresh is not held): read the signed notice, add an entry to
scraper/fishing/amendments.json, then
python3 scraper/build_fishing.py
node scraper/fishing/test_fishing.js
The build checks the entry against the signed notice itself and against UDWR's summary, and fails if the wording or the dates differ. If the entries or their stretches have changed it says "THE PLACES MUST BE REBUILT NOW"; do it, because the phone will not match places to rules built from different editions.
When UDWR reissues the guidebook (every year, around November for the
following year): run the same two commands. Rules whose wording did not change
keep their sorting. A new or changed sentence fails the build and names itself;
read it and, if no pattern fits, add it to scraper/fishing/overrides.json.
Check the page number in scraper/fishing/statewide.json, and re-read the 39
general rules and 7 spearfishing rules there against the new pages: they are
quoted by hand. Then rebuild
the places (below), because the places file is tied to one build of the rules and
the phone will not use it with another. Then have the result audited against the
page images before it ships: the 2026 build was, twice, by independent readers
(see "Audits").
Once a year, or when UDWR's layers change:
python3 scraper/build_fishing_places.py --drive # UDWR pull about 8 minutes, then drive times from the twelve anchor towns
To redo only the drive times (the anchor towns changed), without a UDWR pull, see "Anchor towns and drive times" above.
Both builders need pdftotext and pdftohtml (brew install poppler). Neither
is part of the daily job, on purpose.
| Source | Used for |
|---|---|
| UDWR Fish Utah planner | 292 waters: position, UDWR's point for directions, species with UDWR's own "likely" or "possible", recent stocking |
| UDWR community fisheries layer | community ponds the planner lacks |
| UDWR property layer (already pulled daily) | 107 angler access points and other properties whose stated purpose includes fishing |
| UDWR lake registry | a position for 31 guidebook lakes the planner does not carry, each matched by a person |
| UGRC county outlines | which county a point is in |
| OSRM | road time and road miles from each of the twelve anchor towns (town-centre points) |
A place is linked to a guidebook entry by itself only when the whole name and
the county both match. Every other link is a decision recorded with its reason
in scraper/fishing/links.json: which entry, which stretch, or that the place is
on the water but outside every stretch the guidebook lists, in which case the
sheet says why. The decisions rest on USGS's stream, lake, road and place-name
maps. UDWR's planner also carries regulation text, but most of it was last touched
in 2019, so the app does not show it; the builder reads it only as a second
source, to catch a rule filed under the wrong water. A directions point UDWR gives
that lies more than 15 miles from its own point for the water is not used.
The 2026 build was read against the page images by independent checkers before it
shipped, and the code was reviewed three times. What they found, and what was done, is
in the save points of 2026-09-28. The tests (node scraper/fishing/test_fishing.js)
run under six clocks with the clock stopped, so they give the same answer on any
day, and hold expected values worked out by hand from the guidebook and a
calendar, not copied from the program. Each fix was also broken on purpose to
prove a test catches it.
links.json under extra_counties.Two plain-text screens sit behind the Menu: Privacy (what is kept on the phone, what
leaves it, whether visitors are counted) and How Ranger Hawk makes money. Both are in
docs/site.js, with the Menu rows in docs/go.js (privacy is the search word for the
first, money for the second). Their wording is fixed and was written to match what the
code does: if you change what the app stores or what it sends over the network, change the
Privacy screen in the same commit. Nothing in v29 earns money. The Home towns page
(v36) is in docs/site.js too, with its Menu row in docs/go.js; the code that reads
and saves the choice is docs/state.js, which loads before every other script.
The paid-link slot. docs/data/ut/paid_links.json ships as {"links": []}. To add one,
append {"slot": "draw", "company": "Name", "label": "What the link says", "url": "https://..."}.
Only https:// links draw. draw is the only slot a screen uses today (the foot of Draw
odds, after the agency's source paragraph, which is never edited); a new placement means a
paidLink('slot') call in that screen and the slot added to PL_PLACED in site.js. The
link is always drawn marked Paid link with rel="sponsored noopener". Neither is
optional: the mark is what the Privacy and money screens promise, and sponsored is what
search engines and the affiliate networks expect. The money screen lists the companies of
the links that are drawn, so it stays true by itself.
The visitor count (on since v31). COUNTER_CODE at the top of docs/site.js is
rangerhawk, so the app sends one image request per screen per app open (the screen name
only, plus the referring site on the first) to GoatCounter's /count pixel endpoint, with
no script loaded. The numbers are at rangerhawk.goatcounter.com. The Privacy screen names
GoatCounter in its "Counting visitors" paragraph. Emptying the code switches counting off
and swaps that paragraph back by itself. GoatCounter's free plan is for non-commercial
use: revisit it before the first paid link goes live. Tests: node scraper/test_site.js.
The app is one page drawn by JavaScript, and most AI crawlers (and some search crawlers) do not run JavaScript, so on their own they see an empty shell. Plain HTML pages, page metadata and crawl files fix that. Nothing here changes what a screen in the app says.
scraper/pages/content.js holds every sentence: the name, the one-line
description, the titles, the hunting and fishing pages, their questions, the
disclaimer and the llms.txt text. Change wording there and nowhere else. The one-line
description must read the same in the home page's meta description, Open Graph, the
structured data, the manifest and llms.txt; the tests hold them equal.scraper/build_pages.js writes docs/hunting/, docs/fishing/, docs/privacy/,
docs/how-ranger-hawk-makes-money/, docs/404.html, docs/sitemap.xml,
docs/llms.txt, the manifest's description, and two marked regions of
docs/index.html: the head tags between seo:start and seo:end, and a short shell
block inside #view that people see until the app draws (never hidden; nothing is
shown only to crawlers). Counts and the refresh date come from docs/data/ut at build
time, so no number is typed by hand. The privacy and money pages carry the in-app
screens' own HTML from site.js, so they cannot say something the app does not.
It runs in refresh.yml every day, before the tests.docs/fishing/waters/<id>/ for each
guidebook water, docs/fishing/community/<id>/ for each community water, and the A-to-Z
index at docs/fishing/waters/. scraper/build_pages.js runs the app's own renderer
(docs/fishing.js) in a static mode at noon on the Utah date of the build, so a page says
what the app says, in UDWR's words, with absolute dates and nothing that means now or
today. In its normal mode the app is unchanged: scraper/fishing/app_sheet_hashes.json
holds the hashes of every sheet as main drew them, regenerated only by hand after a deliberate change to the rules, places, config or state.js (node scraper/fishing/app_sheet_fixture.js --write <saved main fishing.js> <baseline commit>), and
test_fishing.js checks the renderer against them under six clocks. A notice-only water
(no guidebook entry) has a page only while a UDWR emergency change is in force or coming;
the nightly build adds and removes those pages and the sitemap follows. Each page ends
with a button that opens the water in the app (/?water=<id>). The wording sweep exempts
UDWR's words wherever a page quotes them (rule text, rule bullets, community notes, the
limits table, UDWR's notices and its list of changes) and still sweeps every sentence
Ranger Hawk writes.Facebook and Instagram are deliberately not scraped. They are auth-walled, their terms forbid it, and anything built on them breaks within weeks and risks the account. What is used instead: UDWR's news page, Reddit's public feeds, and a hand-curated watch list in the app that you check yourself.
How Reddit is read (rebuilt 2026-09-20). Tested that day: Reddit returns an
empty feed for any search by a logged-out reader, and allows an anonymous reader
about one request per half minute. So the job reads the newest 100 posts of six
communities, one at a time, waiting exactly as long as Reddit's own
x-ratelimit-reset header says. National hunting communities (r/Hunting,
r/elkhunting, r/bowhunting, r/Waterfowl) are filtered for Utah place names; Utah
communities (r/Utah, r/SaltLakeCity) are filtered for hunting words. Matches are
kept for 60 days, because most days have none. This adds about five minutes to
the daily run. r/utahhunting was dropped: its only post dates from 2022.
It is still the most fragile source here. Reddit has said its public feeds may
close, and since November 2025 a new API key needs Reddit's approval even for a
personal read-only script, under terms that are non-commercial. If the feed
dies the app loses nothing else; failures are logged in changelog.json.
seasons.json is the one hunting file you edit by hand, and deliberately so.
(Fishing is kept differently; see "Fishing" above.) Season
dates and bag limits come out of a PDF that changes layout year to year; a
scraper that guessed at them would eventually be confidently wrong about a legal
limit. The job flags the reissue, a human reads the table, the file gets edited.
A row may carry units: the list names, spelled as hunt_units_2026.json spells
them, that its dates apply to. Today the finder reads it on one row, the early
any-weapon deer hunt (deer-early-rifle), and prints that row only on those
units: in 2026 it is on 12 of the 31 general-season units (Field Regulations
p.45), so the other 19 show the October rifle dates alone. units is for the
code; area is the sentence a hunter reads. A row may also carry except (printed on
every unit but these) and unit_notes (list name to that unit's own words: the sentence
after the dates, or {"t": "restricted rifle", "s": "..."} to change the title word too).
In 2026: no general archery elk on Paunsaugunt (except); Book Cliffs spike archery is
spike-only (unit_notes); on Beaver West, Boulder/Kaiparowits and Cache the general
muzzleloader and rifle deer hunts are restricted-weapons hunts, no scopes of any kind
(unit_notes on both rows). All from the Field Regulations, pages 5, 31, 45 and 50.
Run locally:
python3 scraper/refresh.py && python3 scraper/build_calendar.py
No dependencies — standard library only, so the Actions run cannot break on a package upgrade.
Checked 2026-09-20 against what is current and maintained on GitHub.
| Piece | Used for | Licence |
|---|---|---|
SunCalc 2.0.1, vendored in docs/vendor/ | Sunrise and sunset for legal light, offline | BSD-2-Clause |
| MapLibre GL JS 5.24.0, vendored | Draws the map | BSD-3-Clause |
| PMTiles 4.5.0, vendored | Reads one map file by byte range, no tile server | BSD-3-Clause |
| Protomaps basemaps 5.7.2 + fonts and icons, vendored | Map style; data extract docs/maps/utah.pmtiles | BSD-3-Clause code; map data (c) OpenStreetMap, ODbL - attribution is shown on the map and must stay |
| USDA NRCS SNOTEL | Snow depth at four high-country gauges | public domain |
| maplibre-contour 0.1.1, vendored | Draws contour lines on the phone from elevation tiles | BSD-3-Clause |
| Mapterhorn elevation tiles (USGS 3DEP in the US) | Contours and hill shading | open; attribution shown on the map |
| UDWR big game draw results (PDF) | Draw odds by hunt and point level | public record |
| BLM Ground Transportation Linear Features and BLM Utah OHV designations | BLM roads, trails and area travel rules | public domain |
docs/data/ut/cam_rules.json, researched 2026-09-21. High confidence (agency or
state code read directly): Utah, Arizona, Nevada, Idaho (new for 2026), Alaska.
Medium (legal-code mirrors or consistent reporting): Kansas, Montana, New
Mexico, Wyoming, Colorado, Oregon, Washington. Low: California. Re-check every
summer - Idaho changed in 2026, Nevada and Washington have changes under
discussion, Montana had a bill moving. The file says so per state, and the app
labels it as not legal advice.
docs/data/ut/draw_odds.json (about 600 KB) is built by scraper/build_odds.py
from three UDWR PDFs per year - limited entry and once-in-a-lifetime, general
buck deer, antlerless - at wildlife.utah.gov/pdf/bg/<year>/. 2025: 917 hunts.
2024: 882. The parser checks itself: every hunt's rows must add up to the
hunt's own Totals line, and odds recomputed from applicants and permits are
compared with the ratio UDWR printed. Known UDWR print quirks it handles: past
1 in 1,000 the PDF drops the leading "1"; and on two 2025 hunts UDWR's printed
ratio disagrees with its own counts (the counts, which reconcile to the Totals
line, are used; the disagreement is logged).
Run it by hand once a year after results post (late May / June):
python3 scraper/build_odds.py (needs pdftotext: brew install poppler).
It is not in the daily job. These are last year's results, not a forecast -
no point-creep model, no simulation. UDWR's newer odds pages live at
utahdraws.com/drawodds; when the 2026 PDFs appear, add 2026 to YEARS.
docs/maps/land.pmtiles (6.5 MB) is all 16,045 parcels of the Utah Trust Lands
ownership layer - BLM, National Forest, state trust (SITLA), DWR, state parks,
National Park, refuge, military, tribal and private - cut into an offline vector
file with tippecanoe (BSD-2). It draws under
the roads, has a legend and an on/off button, saves offline with the basemap, and
a tap anywhere names the owner. Source, pulled 2026-09-21:
https://gis.trustlands.utah.gov/mapping/rest/services/Land_Ownership/FeatureServer/0.
To rebuild: page the layer to GeoJSON, reduce each parcel to a class code, then
tippecanoe -f -o docs/maps/land.pmtiles -l land -Z5 -z12 \
--coalesce-densest-as-needed --detect-shared-borders --simplification=4 land.geojson
Ownership is a guide, not a survey. Parcels are simplified, the state updates the layer on its own schedule, and public ownership does not by itself mean open to hunting or legally reachable. No licence text is published on the service; the state's open-data practice is free use with credit, which the map shows.
docs/maps/mvum.pmtiles (3.4 MB) holds every road and motorized trail the Forest
Service designates as open in Utah's forests - 8,287 road segments and 1,629
trails across Uinta-Wasatch-Cache, Ashley, Fishlake, Manti-La Sal and Dixie, plus
the slivers of Sawtooth, Caribou-Targhee and Humboldt-Toiyabe inside the state.
Public domain. Pulled 2026-09-21 from
https://apps.fs.usda.gov/arcx/rest/services/EDW/EDW_MVUM_01/MapServer (layer 1
roads, layer 2 trails, Utah bounding box).
Each segment carries a legal open window per vehicle class. The build keeps two: truck (high clearance, else 4WD, else passenger car) and ATV (ATV, else other wheeled OHV), stored as month*100+day so the map can colour a road green if that vehicle may be on it today, red if it is seasonally closed today, grey if that vehicle is never allowed. Trails draw dashed. Road numbers label from zoom 11. Tap a road for its number, name, every vehicle class and its dates, surface and maintenance level. A Truck / ATV switch recolours the map. Date logic handles winter windows that wrap the new year (tested).
tippecanoe -f -o docs/maps/mvum.pmtiles -l mvum -Z7 -z12 \
--drop-densest-as-needed --simplification=3 mvum.geojson
The MVUM is the legal document; this is a copy of its data. Forests publish emergency and fire closures separately and those are not in here. A road missing from the MVUM is closed to motor vehicles even if it exists on the ground. BLM and state roads are not covered - this is National Forest land only.
docs/maps/blm.pmtiles (8 MB) holds two BLM layers, pulled 2026-09-21, public
domain:
gis.blm.gov/.../BLM_Natl_GTLF_Public_Display): 38,881
roads open to public motor vehicles, 927 limited roads and trails, and 860
non-motorized or unassessed trails. They share the Forest Service colour logic:
green open, grey not for this vehicle, red seasonally closed, plus amber for
"limited" routes where BLM gives no readable detail (about 1,200). Limited
routes were read into plain classes: width limits of 50-66 inches (ATV / UTV yes,
truck no), motorcycle single-track, ranch / admin / permit only (not public),
and one seasonal window.Coverage is uneven, and this matters. BLM has published routes for southern and eastern Utah - thousands per degree square around Moab, Price, Richfield, Kanab, Monticello and Vernal - but almost none for the West Desert, Tooele and Box Elder (the Salt Lake Field Office). There the map shows the area rule only and says so. Unlike the Forest Service map, a BLM road missing here is not thereby closed: in a "Limited to existing routes" area an existing road is legal. BLM lists no seasonal dates in this data; wet-weather, fire and wildlife closures are posted on the ground.
Pre-drawn contour lines for a whole state run to hundreds of megabytes. Instead the app carries compact elevation tiles and draws the contours on the phone with maplibre-contour 0.1.1 (BSD-3); MapLibre shades the hills from the same tiles.
docs/maps/terrain-utah.pmtiles (60 MB) - all of Utah to zoom 10, about 58 m
per pixel.
docs/maps/terrain-detail.pmtiles (40 MB) - zoom 11, about 29 m per pixel, for
two blocks: Wasatch-Uintas-Box Elder (-113.2,39.7 to -109.9,42.05) and
Boulder-Fishlake (-112.4,37.6 to -110.8,39.0).
docs/maps/terrain-sharp.pmtiles (63 MB) - zoom 12, about 15 m per pixel, for
two blocks: Boulder Mountain, the Aquarius Plateau, Thousand Lake Mountain and
Torrey (-112.0,37.8 to -111.0,38.6), and the Wasatch-Uintas core from the Salt
Lake valley to the High Uintas (-112.2,40.0 to -110.3,41.2). One archive, cut
with a two-box --region file. Added 2026-09-21.
All three are extracts of Mapterhorn (terrarium encoding, 512 px WebP tiles; in the US the source is USGS 3DEP, public domain; Mapterhorn asks for the attribution link the map shows). Where a finer file has no tile, the app cuts the parent tile into its quadrant and doubles it with smoothing off (twice over if need be, so a zoom-12 request far from any detail block is built from zoom 10) - smoothing would blend the colour channels separately and corrupt the encoded heights. Checked 2026-09-21: Kings Peak reads 13,465 ft (true 13,528), Timpanogos 11,654 (11,752), Moab 4,032 (about 4,026); the fallback path returns the same heights as its parent.
Contour interval by zoom: 200 ft (index 1,000) at zoom 11, 100 ft (index 500) at 12-13, 40 ft (index 200) from 14. Labels in feet on index lines. Outside the two sharp blocks the 40 ft lines are interpolated from 29-58 m data: good for reading the shape of the country, not for judging a cliff band. Terrain is a separate, optional 100 MB download ("Save terrain") and has its own on/off chip.
pmtiles extract https://download.mapterhorn.com/planet.pmtiles docs/maps/terrain-utah.pmtiles \
--bbox=-114.1,36.95,-109.0,42.05 --maxzoom=10
pmtiles extract https://download.mapterhorn.com/planet.pmtiles docs/maps/terrain-detail.pmtiles \
--region=detail-blocks.geojson --minzoom=11 --maxzoom=11
Zoom 12 (15 m) is 598 MB statewide, so it is added one hunting block at a time.
To add another: add its box to the region file and re-extract the single zoom-12
archive (GitHub's limit is 100 MB per file; the two blocks now in it come to 63 MB),
or teach demSource() to try more than one archive per zoom.
docs/maps/utah.pmtiles is a one-off extract, not part of the daily job (roads
do not change daily, and every rebuild adds 65 MB to the repo history). To
refresh it, once a year is plenty:
brew install pmtiles
pmtiles extract https://build.protomaps.com/<YYYYMMDD>.pmtiles docs/maps/utah.pmtiles \
--bbox=-114.1,36.95,-109.0,42.05 --maxzoom=13
Zoom 13 was chosen by test: dirt tracks and trails are already in the data at 12-13, all of Utah is 65 MB, and zoom 14 would be 141 MB - over GitHub's 100 MB file limit. The service worker answers the map's byte-range reads from the saved copy, which lives in its own cache so an app update never discards it.
An app update installs all or nothing (since v38): if any file in the service
worker's SHELL fails to download, the new worker is dropped, the old one keeps
serving its complete cache, and the browser tries again on a later visit. The old
cache is deleted only after the new one is complete. So every SHELL file must exist
in docs/ under its exact name and be listed once, or no phone takes the update;
scraper/test_site.js checks both, and plays a failed and a good update.
Looked at and deliberately left out: Open-Meteo (its free tier is non-commercial only); Turf.js (half a megabyte to do one point-in-polygon test); Workbox (needs a build step this app does not have); and the one open hunting-regulation dataset on GitHub, which was archived in 2018.
Legal light is computed for Salt Lake City, which is what UDWR's own table uses, and rounded to the safe side. Checked against the 2026-27 guidebook table: it matches to the minute. The guidebook shifts a few minutes by county and remains the legal authority.
Hunt unit shapes are simplified to about 200 m and overlap by hunt type. Near a boundary, the Utah Hunt Planner and the permit govern.
Anchor towns are town-centre points on purpose, so a drive time is from the middle of the town, not from your street. It is stored as minutes, not coordinates.
Eight bird access points are mapped at property centroids (the large marsh units and Wallsburg), so routing runs the last miles down dike roads and overstates the drive. Where the road time is 1.5 times the 45 mph time or more, the phone shows a range (see "Anchor towns and drive times").
Chukar range points are approximate range centroids, not parking areas.
Blackhawk WMA's 2026-27 status is unconfirmed — UDWR's layer still carries stale 2025-26 closure text.
The Deer Creek retriever-dog restriction is forum-sourced, unverified.
docs/hunting/units/<slug>/docs/hunting/units/scraper/build_pages.jsdocs/units.js/?unit=<slug>scraper/test_units.jsscraper/compare_unit_sheets.js <saved-main-dir>docs/robots.txt is written by hand. Every crawler is allowed, AI search and
answer engines and model-training crawlers included; only /maps/ (the offline map
files) is closed. Decision 2026-10-09: allow training too, because the rules are free
and reach is the point. To close training later, give the training group its own
Disallow: /. rangerhawk.com's DNS records are DNS only (grey cloud) at Cloudflare,
so Cloudflare's AI-bot blocking never sees the traffic: this file is the whole policy.page.css are in the service worker's SHELL,
so once the app has been opened online they open with no signal too. (Without that, a
page never opened would get index.html at its own address, and the app's relative
script paths would not load.) The per-water pages are not in SHELL (205 pages, 2.5 MB).
The unit pages and their index are also outside SHELL (141 pages, about 1.4 MB: 140
unit pages and one index); the count and the bytes move with the data. With no
signal, open the water or unit inside the app instead.scraper/test_pages.js sweeps for the words and checks every page's head,
structured data, sitemap, llms.txt and robots.txt.| USFS Motor Vehicle Use Map data |
| Which forest roads are legally open, to what, and when |
| public domain |
| tippecanoe 2.x (build tool, not shipped) | Cuts the land ownership layer into an offline map file | BSD-2-Clause |
| Ray-casting point-in-polygon (a dozen lines, inlined) | Which hunt unit am I in | public technique |
poppler pdftohtml, pdftotext (build tool, not shipped) | Reads the fishing guidebook by position, and a second time by content order | GPL-2.0, used as a command-line tool |
| UDWR Fish Utah planner, community fisheries layer, lake registry | Fishing places, species, stocking | public record |
| UGRC county boundaries | Which county a fishing place is in | public domain |
| USGS Water Services | Great Salt Lake elevation, sites 10010000 and 10010100, parameter 62614 | public domain |
| National Weather Service API | Forecast per access point | public domain |