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../iphoneand../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.
127.0.0.1:3306, with a user that canCREATE DATABASE (the app creates the observing database itself)
libmysqlclient-dev / mariadb-connector-c (for the mysql2 native build)OpenNGC/SIMBAD) degrades gracefully. The app boots and serves the 50 seeded objects with no API keys and no network.
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.
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.
bundle exec rspec
spec/astro_spec.rb reproduces the section 10 validation fixtures (times +/-1 min,angles +/-0.2 deg) -- the astronomy contract. Keep this green.
spec/size_parser_spec.rb, spec/optics_spec.rb, spec/discovery_spec.rb,spec/web_spec.rb cover parsing, FOV/suitability, discovery selection, and the routes.
bin/discover [--limit N] [--force] [--min-alt DEG] # mine OpenNGC + merge curated
bin/refresh_images [--limit N] # cache DSS2 thumbnails
lib/discovery.rb, section 8) mines the vendored OpenNGC CSV underdata/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).
lib/images.rb, section 9-bis) fetch DSS2 color cutouts from CDShips2fits, 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.
Two paths (spec section 11):
public/observing.cgi boots the same app per request viaRack::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.
The app runs at https://astroplanner.kelehers.xyz via path A:
observing.service runs Puma bound to 127.0.0.1:9292as 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.
/etc/observing.env (root-owned, `chmod600, **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_PATH is pinned in the unit so the service doesn't depend on.bundle/config (also subject to the dotfile wipe).
observing.conf (:80) reverse-proxies to Puma; certbot added thehttp->https redirect. Repo copy: deploy/observing.apache.conf.
observing-le-ssl.conf (:443) is the TLS vhost certbot generated. Repocopy: deploy/observing-le-ssl.apache.conf. It sets X-Forwarded-Proto https so the Rack app builds https redirects/links.
mod_proxy, mod_proxy_http, mod_headers, mod_rewrite(all enabled).
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.
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/.
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).
bin/import_imm --create-missing -- opt-in flag that CREATEs imaged(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.)
rackup gem added -- Rack 3 / Sinatra 4 no longer bundle the rackup CLIused by config.ru / bin/server.
./vendor/bundle (.bundle/config) -- the system gemdir isn't writable on the build host. The path is gitignored.
atan2 argument order -- the spec quotes the spreadsheet ATAN2,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.)
/etc/observing.env, not a project dotfile -- anexternal 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).
/var/www/html/astroplanner/web, not /var/www/observing/web-- 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.
db / list / list-db /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.
primary_designation (the canonical best-knowndesignation, 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°).
lib/scheduler.rb, ordering shown objects byculmination 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.
e2e/, headless Chromium) covers the JS-onlypaths (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).
intersect_dark bug fixed. An above-min window straddling local noon (adaytime 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).
deepsky.csv; the importerwas 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.
imm/ also holds a 36 MB .xlsm and a 46 MB.pdf committed in webUpdate1; consider gitignoring + git rm --cached them to keep the repo lean.
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.
lib/site_fit.rb): each row gets a green/yellow/red "Sky" chiprating 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.
/session) — SUPERSEDED by the proposal→schedule plan flowbelow; 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.
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.
objects.quality, bin/fill_quality): computed base fromImm 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).
proposal_<date>_<site>_<scope>) carry their planparameters 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).
notes doubles as data: JSON prefix ⇒ plan metadata (proposal); plaintext ⇒ 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.
web/web/ so the repo cleanlyseparates 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/.