No description
  • Rust 62%
  • JavaScript 19.3%
  • CSS 8.4%
  • Nix 5.6%
  • HTML 4%
  • Other 0.7%
Find a file
Ellie 6cb1b8f6c8 whale: shore-based whale sighting alerts for the Salish Sea
Rust server (axum) polls the Acartia open data cooperative every 5
minutes, filters new+fresh sightings against user-drawn circle areas,
and sends web push (VAPID, pure-Rust crypto via web-push-native) to an
installable PWA — vanilla JS + vendored Leaflet, no build step.

Ships a nix flake with devShell, package, and a hardened NixOS module
(services.whale), plus a Dockerfile/fly.toml alternative. Tested
against a committed snapshot of the real feed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HN9HFmh9iKxeNEnnWbpcbY
2026-08-23 20:52:59 -07:00
src whale: shore-based whale sighting alerts for the Salish Sea 2026-08-23 20:52:59 -07:00
static whale: shore-based whale sighting alerts for the Salish Sea 2026-08-23 20:52:59 -07:00
tests whale: shore-based whale sighting alerts for the Salish Sea 2026-08-23 20:52:59 -07:00
.envrc whale: shore-based whale sighting alerts for the Salish Sea 2026-08-23 20:52:59 -07:00
.gitignore whale: shore-based whale sighting alerts for the Salish Sea 2026-08-23 20:52:59 -07:00
Cargo.lock whale: shore-based whale sighting alerts for the Salish Sea 2026-08-23 20:52:59 -07:00
Cargo.toml whale: shore-based whale sighting alerts for the Salish Sea 2026-08-23 20:52:59 -07:00
Dockerfile whale: shore-based whale sighting alerts for the Salish Sea 2026-08-23 20:52:59 -07:00
flake.lock whale: shore-based whale sighting alerts for the Salish Sea 2026-08-23 20:52:59 -07:00
flake.nix whale: shore-based whale sighting alerts for the Salish Sea 2026-08-23 20:52:59 -07:00
fly.toml whale: shore-based whale sighting alerts for the Salish Sea 2026-08-23 20:52:59 -07:00
README.md whale: shore-based whale sighting alerts for the Salish Sea 2026-08-23 20:52:59 -07:00

whale 🐋

Shore-based whale sighting alerts for the Salish Sea. A Rust server polls the Acartia open data cooperative every 5 minutes, checks new sightings against alert areas we draw on a map, and sends web push notifications to an installable PWA — usually within minutes of a whale being reported. Walk outside, look at the water.

How it works

phone (installed PWA: map, areas, push) ⇄ Rust server (axum)
                                            • polls acartia.io /sightings/current
                                            • pure decision core: new? fresh? inside an area?
                                            • web push (VAPID, pure-Rust crypto)
                                            • one small JSON state file

Notifications fire only for sightings that are new (never seen before), fresh (reported within FRESH_MINUTES, default 120 — the feed has a long tail of hours-late batch uploads), and inside an enabled area (circle; optional species and trust filters). More than 3 alerts in one cycle coalesce into a summary; at most 10 pushes per hour.

Development

nix develop            # rustc, cargo, clippy, rust-analyzer, flyctl
cargo test             # parsing quirks, decision rules, store — all offline fixtures
cargo run -- gen-vapid # one-time keypair for push
env $(cargo run -q -- gen-vapid) PORT=8090 cargo run

Without WHALE_TOKEN auth is disabled (fine on localhost). Web push works on http://localhost in Chromium — enable notifications in settings, then:

curl -X POST localhost:8090/api/inject -H 'content-type: application/json' \
  -d '{"lat":48.43,"lon":-123.30,"species":"Orca","count":3}'

/api/inject fabricates a fresh sighting through the full pipeline (it stays in the deployed build on purpose — it's the end-to-end test tool).

Deploy (NixOS)

The flake exports nixosModules.default with a services.whale module (systemd unit, DynamicUser, state in /var/lib/whale, hardened). In the consuming flake:

inputs.whale = {
  url = "git+https://forgejo.ellie.town/wizzy/whale.git";
  inputs.nixpkgs.follows = "nixpkgs";
};
{ config, whale, ... }: {
  imports = [ whale.nixosModules.default ];
  services.whale = {
    enable = true;
    port = 8090;
    # WHALE_TOKEN, VAPID_PRIVATE_KEY, optional VAPID_SUBJECT — e.g. a sops template
    environmentFile = config.sops.templates."whale.env".path;
  };
}

Put an HTTPS reverse proxy in front (service workers and web push require a secure context). Binding defaults to 0.0.0.0; isolate with the firewall rather than binding a WireGuard address that races interface bring-up.

Deploy (Fly.io, alternative)

fly launch --copy-config --no-deploy   # pick a unique app name
fly volumes create whale_data --region sea --size 1
cargo run -- gen-vapid                 # then paste into:
fly secrets set WHALE_TOKEN=$(openssl rand -hex 24) \
  VAPID_PUBLIC_KEY=... VAPID_PRIVATE_KEY=... \
  VAPID_SUBJECT=mailto:you@example.com
fly deploy

auto_stop_machines = "off" in fly.toml is load-bearing — the poller dies if Fly stops the machine. Expect a few dollars a month; note Fly has no spending cap.

Phone setup

Open https://<app>.fly.dev/#t=<WHALE_TOKEN> once per device (the fragment becomes a year-long cookie and never appears in server logs). Install the PWA — Android offers it; on iPhone use Share → Add to Home Screen (iOS only allows push for installed PWAs). Launch from the home screen, enable notifications in settings, send the test push, then draw an alert area.

Environment

var default
PORT 8080
DATA_DIR data state.json lives here (the Fly volume)
STATIC_DIR static the PWA
POLL_INTERVAL_SECS 300
FRESH_MINUTES 120 max sighting age that can still push
ACARTIA_URL acartia.io point at a local file server for offline dev
WHALE_TOKEN (unset = auth off) magic-link shared secret
VAPID_PRIVATE_KEY (unset = push off) from gen-vapid, base64url
VAPID_SUBJECT mailto:whale@example.com contact claim push services see