Skip to content

Repository files navigation

AstroCast

A weather portal for astrophotography, with native iOS and Android companions.

CI License: MIT

Laravel 13 · Vue 3 · C# / .NET · SwiftUI · Kotlin + Jetpack Compose · PostgreSQL · Docker

AstroCast home screen


Search any location and see a blended, multi-source hourly forecast (cloud cover by altitude, seeing, transparency, wind, humidity, precipitation, visibility, pressure), darkness/twilight windows, moon phase and rise/set, and an approximate sky-quality (light pollution) rating — in one clear, dark-themed view. The same data and account system is also available from native iOS and Android companion apps, which add device location, biometric-gated sign-in, and a saved-locations/equipment/gallery experience on top of the portal's /api/mobile/* surface.

Favorite locations, equipment, and a public photo gallery/profile are built; an observation-session log is designed but not yet built (see Roadmap below).

Part Stack Role
webapp/ Laravel 13 · Inertia.js · Vue 3 The web portal — accounts, admin portal, search, forecast rendering, and the /api/* surface used by the browser and both mobile apps
backend/AstroCast.Worker/ C# / .NET Internal-only service that blends weather from three free sources, computes astronomy (twilight, moon), and persists results to Postgres — never reached directly by a browser or mobile app
ios/AstroCast/ SwiftUI, iOS 17+ Native iOS companion app, talking to the portal's /api/mobile/* endpoints over a Bearer token
android/AstroCast/ Kotlin + Jetpack Compose, minSdk 26 Native Android companion app, talking to the same /api/mobile/* endpoints

Contents


Portal — build & run

Requires Docker.

docker compose up --build

Then open http://localhost:8080. That one command builds and runs five containers:

  • postgres — shared data store
  • worker — C# service that fetches/blends weather + computes astronomy and persists it to Postgres; internal-only, never reachable from a browser
  • laravel-fpm — the Laravel/Inertia/Vue app (PHP-FPM process)
  • laravel-queuephp artisan queue:work, runs background jobs (e.g. triggering the worker to refresh a location that's missing/stale)
  • laravel-nginx — serves the built frontend assets and proxies PHP requests to laravel-fpm; the only container with a published port (8080)

An admin account is seeded automatically from ADMIN_EMAIL/ADMIN_PASSWORD in the repo-root .env (see Accounts & admin portal).

Running the test suite

docker compose --profile test build laravel-test
docker compose --profile test run --rm laravel-test

This is also what CI runs on every push/PR (.github/workflows/ci.yml), against a real Postgres service container — several admin queries use Postgres-only SQL that SQLite can't parse, so the suite always targets Postgres, never an in-memory DB.

Local development (without Docker)

Run Postgres via Docker (docker compose up -d postgres, exposed on the host at the port in .env), then:

# terminal 1 — worker
cd backend/AstroCast.Worker
dotnet run --urls http://localhost:8081

# terminal 2 — Laravel
cd webapp
php artisan serve --port=8001

# terminal 3 — queue worker (needed for the cold-path refresh flow)
cd webapp
php artisan queue:work

# terminal 4 — Vite dev server (optional; `npm run build` once also works)
cd webapp
npm run dev

webapp/.env already points at the host-mapped Postgres port and http://127.0.0.1:8081 for the worker.

Portal — deploying

The portal ships as the same five-container Docker Compose stack in both development and production — there's no separate production build system. To deploy to a server:

  1. Provision a host with Docker + Docker Compose, and point a domain at it (the app assumes it sits behind HTTPS in production — either terminate TLS with a reverse proxy such as Caddy/nginx/Traefik in front of the published 8080 port, or adapt laravel-nginx's config to terminate TLS itself).
  2. Copy the repo to the host (or pull it via CI/CD) and create a production .env at the repo root (never commit real secrets — copy .env and fill in real values):
    • APP_ENV=production, APP_DEBUG=false
    • POSTGRES_DB / POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_PORT — real, non-default credentials
    • ADMIN_EMAIL / ADMIN_PASSWORD — seeds the one admin account on first boot
    • WORKER_API_TOKEN — shared secret between Laravel and the worker's internal API
    • MAIL_HOST / MAIL_PORT / MAIL_USERNAME / MAIL_PASSWORD / MAIL_FROM_ADDRESS — see Configuring outgoing email; without these, email verification and clear-sky alerts silently fail to send
    • An APP_KEY for webapp/.env — generate one with docker compose run --rm laravel-fpm php artisan key:generate --show and set it in webapp/.env (or bake it into your image build) before first boot
  3. Build and start the stack:
    docker compose build
    docker compose up -d
    laravel-fpm (only) runs pending migrations and idempotent seeders on container start — gated by its RUN_MIGRATIONS=true env var, so laravel-queue and laravel-nginx (which share the same image) don't race it (see webapp/docker/entrypoint-fpm.sh). A fresh Postgres volume is bootstrapped automatically the first time the stack comes up.
  4. Redeploying after a code change: rebuild only the images that changed and recreate those containers — frontend assets are compiled into the image at build time (an npm run build stage in webapp/Dockerfile), so any JS/CSS/Blade/Vue change requires a rebuild, not just a restart:
    docker compose build laravel-fpm laravel-nginx
    docker compose up -d laravel-fpm laravel-nginx
    For a worker-only change: docker compose build worker && docker compose up -d worker.
  5. Health check: once running, /admin/status (signed in as an admin) reports live health for Postgres, the worker, and the queue worker — useful for confirming a deploy actually came up healthy.

There is no managed hosting/CI deploy step wired up in this repo yet (.github/workflows/ci.yml only runs the test suite) — the steps above are what to automate if you add one.

Mobile app (iOS) — build & run

Requires a Mac with Xcode 15+ (iOS 17 deployment target) and XcodeGen (brew install xcodegen) — the .xcodeproj is generated from ios/AstroCast/project.yml, not committed as hand-edited project state.

cd ios/AstroCast
xcodegen generate
open AstroCast.xcodeproj

Then in Xcode, select the AstroCast scheme and run on an iOS 17+ simulator (or a device, with your own Apple Developer team selected under Signing & Capabilities — CODE_SIGN_STYLE is Automatic).

The app points at a different API base URL per build configuration (AstroCast/Networking/APIClient.swift):

  • Debughttp://localhost:8080 — the iOS Simulator shares the host Mac's network namespace, so this reaches a locally-running docker compose up stack directly, no proxy or LAN IP needed. Info.plist allows insecure HTTP specifically to localhost for this reason.
  • Releasehttps://astrocast.app — a deployed portal instance.

So: start the portal locally first (docker compose up --build from the repo root), then run the iOS app in Debug — sign-up/login/forecast/gallery all talk to that local stack.

Running iOS tests

From Xcode: Cmd+U on the AstroCast scheme, which runs both AstroCastTests (unit — e.g. RatingsTests.swift) and AstroCastUITests. From the command line:

cd ios/AstroCast
xcodebuild test -project AstroCast.xcodeproj -scheme AstroCast \
  -destination 'platform=iOS Simulator,name=iPhone 15'

(iOS builds/tests aren't currently wired into .github/workflows/ci.yml, which only covers the portal — running them is a manual/local step today.)

Mobile app (iOS) — deploying to TestFlight / App Store

  1. Regenerate the project (xcodegen generate) so any project.yml changes are reflected, then open it in Xcode.
  2. Under the AstroCast target's Signing & Capabilities, select your real Apple Developer team (the bundle identifier is pl.adamlange.astrocast, set in project.yml — change it there, not in Xcode directly, if you need your own). Note your Team ID (Membership Details in the Apple Developer portal) and set it as APPLE_TEAM_ID in the portal's production .env — without it, the emailed verification link's Universal Link won't pass Apple's on-device AASA verification and will just open in Safari instead of the app.
  3. Confirm the Release configuration's APIClient.baseURL (https://astrocast.app) points at your actual deployed portal — update ios/AstroCast/AstroCast/Networking/APIClient.swift if you're deploying under a different domain, then re-run xcodegen generate.
  4. Select Product → Archive in Xcode (with an iOS device or "Any iOS Device" as the run destination — Archive is disabled for simulator destinations).
  5. In the Organizer window that opens after archiving, Distribute App → App Store Connect → upload. This requires an Apple Developer Program membership and an app record already created in App Store Connect for pl.adamlange.astrocast (or your own bundle id).
  6. From App Store Connect, add the build to a TestFlight group for internal testing, or submit it for App Review to release publicly.

Mobile app (Android) — build & run

Requires Android Studio (or the command-line tools) with JDK 17+ and a device/emulator running Android 8.0 (API 26) or newer.

cd android/AstroCast
./gradlew :app:assembleDebug

Or open android/AstroCast in Android Studio and run the app configuration on an emulator or device.

The app points at a different API base URL per build type (app/build.gradle.ktsBuildConfig.API_BASE_URL):

  • Debughttp://10.0.2.2:8080 — the emulator's alias for the host machine's loopback interface, reaching a locally-running docker compose up stack directly. A debug-only cleartext network_security_config scopes plaintext HTTP to that one host.
  • Releasehttps://astrocast.app — a deployed portal instance.

So: start the portal locally first (docker compose up --build from the repo root), then run the Android app in Debug — sign-up/login/forecast/gallery all talk to that local stack. (On a physical device instead of an emulator, 10.0.2.2 won't resolve — point API_BASE_URL at your host machine's LAN IP instead.)

Running Android tests

cd android/AstroCast
./gradlew testDebugUnitTest

Runs the JVM-only unit tests (e.g. RatingsTest.kt, ported from RatingsTests.swift) — this is also what CI runs (.github/workflows/ci.yml's android-tests job). Compose UI tests under app/src/androidTest/ (guest-mode navigation, login/logout, search → forecast-detail) need a running emulator plus a live local backend, so they're a manual/local step for now, same as iOS's UI test suite:

./gradlew connectedAndroidTest

Mobile app (Android) — deploying to the Play Store

  1. Generate a release keystore (once) and keep it out of version control:
    keytool -genkeypair -v -keystore release.keystore -alias astrocast \
      -keyalg RSA -keysize 2048 -validity 10000
  2. Add a release signing config to app/build.gradle.kts pointing at that keystore (via environment variables or a local, gitignored keystore.properties — never commit the keystore or its passwords). Then get its SHA-256 fingerprint with ./gradlew signingReport and set it as ANDROID_CERT_FINGERPRINTS in the portal's production .env — without it, the emailed verification link's App Link won't verify and will just open in the browser instead of the app.
  3. Confirm the release build type's API_BASE_URL (https://astrocast.app) points at your actual deployed portal.
  4. Build the release bundle:
    ./gradlew bundleRelease
    producing app/build/outputs/bundle/release/app-release.aab.
  5. Upload the AAB to the Play Console for pl.adamlange.astrocast (or your own application id, set in app/build.gradle.kts), first to an internal testing track (the Play Console's equivalent of TestFlight), then promote to production once verified.

How a search resolves

  1. Typing in the search box hits Laravel's own /api/locations/search, which proxies Open-Meteo's geocoding API directly (no worker hop — it's a stateless, latency sensitive path).
  2. Picking a result navigates to /forecast?.... The controller asks the worker to resolve (get-or-create) that location in Postgres, then checks Postgres itself for freshness: a hourly_forecasts row for the current UTC hour, updated within the last 90 minutes.
    • Warm (common case): reads straight from Postgres, no worker call — fast.
    • Cold/stale: dispatches a queued job that asks the worker to refresh (fetch the 3 weather APIs + compute astronomy + persist); the page renders immediately in a "fetching…" state and polls /forecast/{id}/data every ~1.5s until ready.
  3. The worker also runs a BackgroundService that periodically re-refreshes every known location on the same staleness rule, independent of user requests.

Both mobile apps reach the same forecast data through /api/mobile/* (Bearer-token authenticated, mobile:app + forecast:read abilities) rather than the browser-facing Inertia routes — see ios/AstroCast/AstroCast/Networking/ForecastService.swift and android/AstroCast/app/src/main/java/pl/adamlange/astrocast/network/ForecastService.kt.

Architecture

backend/AstroCast.Worker/
  Services/Providers/    Open-Meteo, 7Timer!, and MET Norway HTTP clients — all free,
                          keyless weather sources (unchanged from the original build)
  Services/
    ForecastAggregator    resamples each provider onto one hourly UTC grid and averages
    AstroCalculator        deterministic sun/moon position, twilight, and moon-phase math
    SkyQualityService       samples a bundled light-pollution atlas image per coordinate
    RefreshOrchestrator     runs the above for one location and persists raw results
    RefreshBackgroundService  periodic PeriodicTimer refresh of stale locations
  Data/                   Dapper/Npgsql repositories (raw SQL upserts; no ORM/migrations
                          on this side — see "Schema ownership" below)
  Endpoints/              Docker-internal only: /internal/locations/resolve,
                          /internal/refresh/{id}, /internal/health

webapp/                   Laravel 13 + Inertia.js + Vue 3
  app/Http/Controllers/    LocationSearchController, ForecastController, plus
                          Mobile* controllers backing /api/mobile/* for the mobile apps
  app/Jobs/                RefreshLocationForecast (queued worker-refresh trigger)
  app/Models/              Location, HourlyForecast, DarknessWindow, MoonInfo, SkyQuality,
                          GalleryPhoto, Equipment, FavoriteLocation
  database/migrations/     Schema source of truth for every table, including the ones
                          the worker writes to (see below)
  resources/js/
    composables/           useRatings (ported RatingService.cs thresholds — Good/Ok/Bad
                          bands are computed client-side, not stored), usePhaseShading,
                          useFormatting
    Components/            SearchBox, ForecastGrid (fit-to-width table + custom
                          scrollbar + phase-shaded header, ported from the original
                          vanilla-JS build), NightCard, AppHeader, Modal
    Pages/                 Home.vue, Forecast.vue, Gallery/Index.vue,
                          PublicProfile/Show.vue, and the admin pages

ios/AstroCast/AstroCast/  SwiftUI app, generated via XcodeGen (project.yml)
  Networking/              APIClient (Bearer-token HTTP), ForecastService, GalleryModels,
                          SettingsModels, ProfileVisibilityModels
  Security/                SessionStore (token persistence), KeychainStore, BiometricGate
                          (Face ID gate on app resume)
  Location/                LocationManager, IPGeolocationService (fallback when the user
                          hasn't granted device location)
  Features/                Auth, Home, Forecast, Locations, Gallery, Settings, Root
  DesignSystem/            Starfield/ConstellationView backgrounds, OutlookDialView,
                          shared color/font tokens matching the web portal's dark theme

android/AstroCast/app/src/main/java/pl/adamlange/astrocast/
                          Kotlin + Jetpack Compose app, package-by-feature, mirroring
                          the iOS app's own structure 1:1 (no DI framework either — a
                          manual AppContainer singleton, matching SessionStore's role)
  network/                ApiClient (Retrofit + OkHttp, Bearer-token interceptor),
                          ForecastService, GalleryModels, SettingsModels
  security/                SessionViewModel (token persistence via EncryptedSharedPreferences),
                          TokenStore, BiometricGate (BiometricPrompt on app resume)
  location/                DeviceLocationManager, IpGeolocationService (fallback when the
                          user hasn't granted device location)
  auth/, home/, forecast/, locations/, gallery/, settings/, root/
                          One package per feature, same split as iOS's Features/
  designsystem/            Starfield/ConstellationView Canvas backgrounds, OutlookDialView,
                          Ratings.kt (byte-for-byte port of the same thresholds), shared
                          color/font tokens matching the web portal's dark theme

Schema ownership

Laravel migrations are the single source of truth for every table, including locations, hourly_forecasts, darkness_windows, moon_info, and sky_quality which the C# worker writes to. The worker talks to the same Postgres via Dapper/Npgsql with hand-written SQL (including ON CONFLICT upserts) — no EF Core, no second migration system. Extending the schema means adding a Laravel migration; the worker's repositories just need matching column names.

hourly_forecasts is upserted per (location_id, time_utc): future hours get overwritten as forecasts improve, and past hours simply stop being touched — that's the historical record, with no separate archive table. darkness_windows/moon_info are deterministic astronomy, so they're computed once per (location_id, date) and never recomputed.

Why the numbers won't exactly match clearoutside.com

Clear Outside is powered by a paid, proprietary weather provider (Meteosource, formerly Dark Sky/Forecast.io) whose exact cloud-cover/seeing algorithm is not published. This project instead blends three free, open sources and applies its own Good/Ok/Bad thresholds — see webapp/resources/js/composables/useRatings.js — so the shape of the forecast is comparable but the exact numbers won't line up. A direct comparison against live Clear Outside data (pressure/temperature/darkness/moon match closely; cloud cover diverges as expected between different models) is summarized in project history.

Two things do match any correct reference exactly, since they're deterministic astronomy rather than weather-model output: civil/nautical/astronomical twilight times and moon phase/rise/set (backend/AstroCast.Worker/Services/AstroCalculator.cs).

Sky quality is a rough, clearly-labeled approximation derived from a static 2024 light-pollution atlas (see backend/AstroCast.Worker/assets/lightpollution/README.md for provenance and licensing notes) — not real-time data, and not the atlas author's own Bortle metric (he explicitly asks that the two not be conflated).

Accounts & admin portal

Breeze auth (login/register/profile) is wired into the header — "Log in"/"Register" when signed out, name + "Settings" + "Log out" when signed in — and restyled to match the app instead of Breeze's default light theme. The same account system authenticates both mobile apps via a separate Sanctum ability (mobile:app) rather than session cookies.

An admin account is seeded on deploy (database/seeders/AdminUserSeeder.php) from the ADMIN_EMAIL / ADMIN_PASSWORD environment variables — idempotent via firstOrCreate, so it never overwrites an existing user's password, and is_admin is only granted at first creation. If either variable is unset, the seeder logs a warning and skips creating an admin account. Admin-ness is a single users.is_admin boolean, enforced by the admin middleware alias (app/Http/Middleware/EnsureUserIsAdmin.php) on every /admin/* route.

Signed-in admins see an "Admin" link in the header, leading to:

  • /admin/users — list every account, promote/demote admin status, delete users (both blocked against targeting your own account).
  • /admin/status — live health for all three backend services: Postgres (connection + response time + row counts), the C# worker (/internal/health + time since its last successful write to hourly_forecasts), and the queue worker (pending/failed job counts + a cache-based heartbeat written by RefreshLocationForecast on every run, since there's no direct way to HTTP-ping a long-running artisan queue:work process).

Configuring outgoing email

Email verification and clear-sky alert notifications need real outgoing mail. By default, MAIL_MAILER=smtp with placeholder host/from-address values (.env at the repo root, plus webapp/.env/.env.example) — sends will simply fail (logged as a warning, not fatal to the app) until you fill in a real provider's MAIL_HOST/MAIL_PORT/MAIL_USERNAME/MAIL_PASSWORD/MAIL_FROM_ADDRESS. For Docker, set these in the repo-root .env (picked up by docker-compose.yml's laravel_env anchor); for local (non-Docker) dev, set them directly in webapp/.env.

If your provider's mail box is shared/reseller hosting, its TLS certificate may not match MAIL_HOST (e.g. it presents the box's own hostname instead) — sends will fail with a did not match expected CN error even with correct credentials. Set MAIL_TLS_PEER_NAME to whatever hostname the certificate is actually issued for, and config('mail.mailers.smtp.tls_peer_name') (wired up in AppServiceProvider) pins TLS verification to that name instead of disabling verification altogether.


Roadmap (designed, not built yet)

  • Observation session log: let users log their own imaging sessions (date, location, notes, equipment, rating) against the historical weather data already sitting in hourly_forecasts/darkness_windows/moon_info.
  • iOS CI: wire xcodebuild test into .github/workflows/ci.yml alongside the existing portal and Android test jobs, so mobile regressions are caught the same way.
  • Android instrumented UI tests in CI: android-tests currently only runs the JVM-only unit tests; running connectedAndroidTest would need an emulator (or Firebase Test Lab) plus a live backend service in the CI job.

About

Astrophotography weather forecast suite

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages