Observing Web App

This is the web component of the repo. It lives in web/ and is fully self-contained — run every command below from this directory. The iPhone and Mac apps live in ../iphone and ../mac; repo-wide conventions are in ../CLAUDE.md.

A self-hosted Ruby (Sinatra + Sequel + MySQL) observing planner for deep-sky astrophotography. It computes live altitude/azimuth/rise/transit/set for a catalog of objects from observer coordinates + date/time, filters by minimum altitude (showing the interval each object spends above it), sorts by any column, shows DSS sky-survey thumbnails, and discovers new targets suited to the owner's telescopes (William Optics RedCat 71 & MiniCat 51 on a ZWO ASI2600MC).

The authoritative specification is ObservingWebapp.md (in this directory). Working conventions are in ../CLAUDE.md.

Prerequisites

CREATE DATABASE (the app creates the observing database itself)

OpenNGC/SIMBAD) degrades gracefully. The app boots and serves the 50 seeded objects with no API keys and no network.

Setup

cp .env.example .env          # set DB_USER / DB_PASSWORD (+ DB_NAME etc.)
bundle install                # installs into ./vendor/bundle
bin/setup                     # create DB -> migrate -> seed 50 objects + equipment + observer
bin/setup --discover          # ...and run a first OpenNGC discovery pass

bin/setup is idempotent -- safe to re-run. It never requires internet.

Run locally

bin/server                    # Puma on http://localhost:9292

Open <http://localhost:9292>. The left column holds the observer block and the sort / filter / equipment controls; the table on the right is recomputed server-side on every change. Clicking a column header sorts (and toggles direction) via a server round-trip -- sorting is always done in Ruby/SQL on the server, never in fragile client-side handlers.

Tests

bundle exec rspec

angles +/-0.2 deg) -- the astronomy contract. Keep this green.

spec/web_spec.rb cover parsing, FOV/suitability, discovery selection, and the routes.

Discovery & images

bin/discover [--limit N] [--force] [--min-alt DEG]   # mine OpenNGC + merge curated
bin/refresh_images [--limit N]                       # cache DSS2 thumbnails

data/openngc/ (commit recorded in COMMIT.txt) for big, bright, wide-field-friendly targets within the scopes' FOV bands and reachable declinations, dedupes on primary_designation, and merges the curated supplement in data/curated_targets.yml. Each run is logged in import_runs. Idempotent; offline-capable (curated list still merges if the CSV is absent).

hips2fits, sized from each object's apparent size, and cache them under public/cache/thumbs/. Failures leave image_cached_path NULL and render a placeholder. Also exposed via POST /admin/refresh_images.

Deployment (kelehers.xyz)

Two paths (spec section 11):

Rack::Handler::CGI (note the cold-start cost).

DB connection is via the DB_* env vars in both cases; bin/migrate and bin/seed are available standalone.

Live production setup (astroplanner.kelehers.xyz)

The app runs at https://astroplanner.kelehers.xyz via path A:

  1. systemd service observing.service runs Puma bound to 127.0.0.1:9292

as user keleher, with WorkingDirectory=/var/www/observing/web (the app is a subdirectory of the repo). Repo copy: deploy/observing.service. Manage with sudo systemctl {status,restart} observing.

600, **not** in the repo). This lives in /etc rather than a project dotfile because an external agent on this host periodically wipes dotfiles under /var/www (it deleted .env and .bundle/config during setup); /etc/observing.env` is immune and keeps the secret out of the repo.

.bundle/config (also subject to the dotfile wipe).

  1. Apache vhosts (per the host's per-subdomain convention):

http->https redirect. Repo copy: deploy/observing.apache.conf.

copy: deploy/observing-le-ssl.apache.conf. It sets X-Forwarded-Proto https so the Rack app builds https redirects/links.

(all enabled).

  1. TLS via Let's Encrypt (sudo certbot --apache -d astroplanner.kelehers.xyz),

auto-renewed by certbot.timer. Cert at /etc/letsencrypt/live/astroplanner.kelehers.xyz/.

To re-provision on a fresh host, the generic templates deploy/nginx.conf.example and deploy/apache.conf.example show a path-based (/observing) proxy variant.

Project layout

app.rb (routes) | lib/ (pure, tested: astro, size_parser, optics; side-effecting: planner, discovery, images, timezone, seeder, models) | config/database.rb (DB bootstrap) | db/migrations/ | data/ (seed YAML, curated YAML, vendored OpenNGC) | views/ | public/ | bin/ | spec/ | deploy/.

Decisions & deviations

Recorded per CLAUDE.md when the spec left a choice or reality differed:

2026-06-14 feature pass asked to surface often-imaged targets and "maybe toss" the rest. We use the Imm Deep Sky Compendium imm_rating (1..5) as the imaging-popularity signal, but keep the full catalogue searchable (non-destructive); the curated lists and discovery focus on rated objects.

(size_major >= 10'), Most imaged bright nebulae, built from imm_rating (Seeder.seed_imaged_lists! / bin/seed_lists). Idempotent; no-op until the catalog has ratings (run bin/import_imm first).

(rating >= 1) targets the catalog lacks, with RA/Dec/type mapped from the CSV (source: "imm"). Off by default; non-destructive.

astronomical-dark window: blue = below horizon, red = up-but-below-min-alt (min defaults to 0), green = at/above min. (Was a noon->noon daylight bar.)

used by config.ru / bin/server.

dir isn't writable on the build host. The path is gitignored.

whose argument order is (x, y), the reverse of Ruby's Math.atan2(y, x). lib/astro.rb swaps accordingly; the section 10 azimuth fixtures confirm it.

.../api/timezone/coordinate (the .../Time/current/coordinate endpoint dropped it). lib/timezone.rb uses the TimeZone endpoint. Google Time Zone API is still used when GOOGLE_TZ_API_KEY is set.

(interval column then ~ rise-set).

primary_designation (e.g. M31 not NGC224) so OpenNGC rows dedupe against the Messier-named seed rows.

21:00 local; change them in the observer block. (bin/setup seeds the Silver Spring, MD site as the default location.)

external agent on the host wipes dotfiles under /var/www (observed deleting .env and .bundle/config). The systemd service reads /etc/observing.env (root-owned) instead; CLI tools still use .env/dotenv, so recreate .env from .env.example if it disappears (or source /etc/observing.env).

-- the observing repo was copied to astroplanner and the web code moved under astroplanner/web (siblings: iphone, mac). The systemd unit's WorkingDirectory, BUNDLE_PATH, and BUNDLE_GEMFILE all point there; deploy/observing.service is kept in sync. Redeploy with sudo cp deploy/observing.service /etc/systemd/system/ && sudo systemctl daemon-reload.

webUpdate2 (todo.md): list modes + planner UX

edit), driven by list_id + a db toggle + an edit flag. The standalone /lists, /lists/:id and schedule pages and the "save results to list" bar were retired (views lists.erb, list.erb, schedule.erb deleted). The ObservingList/Scheduler models and lib/scheduler.rb are kept and reused.

selected objects" is interpreted as the current filtered/shown set (there is no per-row multi-select); e.g. filter galaxies in db mode, hit New.

db toggle"; in list-db the toggle is already checked, so the implemented behavior is the sensible inverse: unchecking it returns to list mode. edit takes precedence over db in resolve_mode.

designation, e.g. M1) rather than the leading segment of the free-text catalog_number (which for bulk-imported rows is the bare NGC1952). Sort and display now agree; the full catalog_number is the cell's title tooltip.

(~0.05 ms each), so the todo's "cache in the DB indexed by object" was skipped: caching would add write-on-read and stale-invalidation across site/min changes for no measurable gain. Effective min altitude = max(userMinAlt, 20°).

culmination across the night's astronomical-dark window (fallback 6pm-6am).

on change (debounced while typing); the Sort panel was removed since every sort key already has a clickable column header.

is the pre-existing per-row ephemeris cost (compute_row), not the tonight bar (~20 ms). Narrowing with any filter is fast. Flagged for a future optimization pass if it becomes annoying.

paths (mode toggles, +/- icons, group checkbox, inline expand, delete-all confirm); rack-test specs (spec/list_modes_spec.rb) cover the server-route contract underneath. Run with cd e2e && npx playwright test against the running app (BASE_URL to override).

webUpdate2 round 2 (todo.md feedback)

daytime object such as IC341) was mis-reported as observable all night. It is now intersected correctly in the night frame, yielding viewable_hours per row (0 for daytime-only objects).

during astronomical darkness are excluded (IC341 disappears). min_alt = 0 still shows everything (per decision). "Show hidden" removed.

"Min size" / "Max size" (arcmin, largest dimension = size_major_arcmin). Unknown-size objects are EXCLUDED when a size filter is active (unlike the magnitude filter), so "large" means large. size_major_arcmin is the parsed largest dimension already used for both the size sort and these filters (apparent_size text is display-only); sizes are clean arcminutes — "M×m′" or "single′" — so no separate numeric column is needed.

fragment under the row (click again to close); the planner no longer links out to /object/:id. That full-page route still exists for direct URLs but isn't linked. The inline fragment shows a TheSkyLive link built from the NGC number when available (else IC/Messier slug); pattern ngc<N>-object confirmed from the example URL (TheSkyLive 403s automated requests, so the Messier slug is a best-effort guess — most objects resolve via their NGC number anyway).

no indeterminate state.

visibility math — only the Moon column + proximity warning).

becomes interactive once a list is selected (db mode keeps it checked+disabled).

was repointed to it. The ~586 still-missing magnitudes are NGC/IC objects absent from the Imm compendium, so imm2026 cannot fill them — that would need a different catalog source.

.pdf committed in webUpdate1; consider gitignoring + git rm --cached them to keep the repo lean.

Scheduling, priorities, sky quality (observingPlanFable.md)

Lorenz's light-pollution atlas — static 5°×5° binary tiles fetched once and cached in data/cache/lp_tiles/, so repeat lookups are offline and a failed lookup just leaves the field manual. ratio→Bortle is a least-squares fit calibrated to the plan doc's own site values (Sky Meadows 4.5, Spruce Knob 3.2, Little Bennett 5.9); bin/fill_bortle backfills.

rating the object against the current site's Bortle per the doc's strategy rules (NB-able types tolerate ~B6; broadband at moderate sites only when bright; faint dust is dark-site-only; clusters punch through). Thresholds are judgment calls, unit-tested against the doc's sites; tweak in lib/site_fit.rb. "Fit at this site" filter hides poor-rated targets.

night plan; the proposal flow's checkbox winnowing replaced their purpose, so the chips, the /list/:id/priority route, and the plan-order weighting were deleted. The observing_list_objects.priority column (migration 009) remains in the schema, unused, to keep migrations additive.

below; the two-rig page and Scheduler.allocate were removed. Original design, for the record: allocates the current list across the selected night's dark window on two parallel rig tracks (defaults: widest-FOV scope = Rig A, FL nearest 800 mm = Rig B, both overridable). Faithful to the doc's night-by-night style: the P1 target takes its full usable window (never split to share), later targets fill the gaps (dusk/dawn slices fall out naturally), spillover is listed as unscheduled with reasons. Rig matching by FOV fill (~40% sweet spot; unknown sizes: galaxy-ish types → narrow rig). Effective min altitude 20°, blocks ≥45 min. The night picker shades the month's nights by moonlight (doc rule 6: shoot within ±5 days of new moon). Single-night for now; the multi-night campaign view (date ranges + integration budgets) is the planned next layer.

Plan flow (proposal → schedule → plan)

scopes on a night = two plans. Setup lives in the Observer panel (site, Date, End, Plan scope); Plan in the LIST panel runs against the selected "prefs" list.

Imm rating / Messier–Caldwell / common name / Wikipedia / size+brightness, with ~150 consensus showpieces pinned in data/quality_overrides.yml (one-time web tabulation). Proposal ranking = quality (dominant) + framing fit vs the scope FOV (~40% fill sweet spot; spillovers cut) + site fit (poor-rated cut) + visibility hours (≥1 h above 20° in darkness required).

parameters as JSON in observing_lists.notes; the proposal view adds checkboxes + "Schedule checked". Re-planning the same night/site/scope replaces the proposal; accepting deletes it.

(windows from the same dark-intersection math as the table; blocks ≥45 min, stretched to leave no dead air). Conflicts → leave-one-out alternatives, then a best-effort subset. Accepted plan = plan_<...> list whose notes is a plain-text timetable, rendered as a preface above the table; multi-night ranges get a "−4 min/night" drift note (night-1 times).

text ⇒ display preface (accepted plan). Fieldset min-width: 0 fix landed with this work — long proposal names in the list dropdown were silently widening the LIST panel over the table's first column and swallowing clicks.

Relocated into web/

separates its three front-ends (web, iPhone, Mac). All app paths are repo-relative and unchanged; production deploy is now /var/www/observing/web (deploy/observing.service, BUNDLE_GEMFILE/BUNDLE_PATH updated). The Swift apps' catalog exporter ../mac/Scripts/export_catalog.rb was repointed to web/config/database, and the reverse-proxy cache aliases in deploy/*.example now point at web/public/cache/.