Skip to content

Sky science

Offline and sync

What works without a network today, how each cache expires, how background sync chooses locations, and what is not built yet.

What works offline

DataOffline?How
Zone (light pollution) for places you have viewed or savedyesHive cache, one entry per H3 cell, valid for a year
Weather for places you have viewed or savedyes, possibly staleHive cache of hourly and seven-day forecasts
Sun, Moon and planet positions, rise and setyescomputed on the device
Sky state and graphsyes, from cached inputspure functions of the cached data
Zone for a new place with no networkdark-sky defaultzone 1 is assumed, not measured
Object catalogueyes, but tiny todaybundled SQLite file with 3 deep-sky objects and 4 stars
The full zone databasenot builtsee below

Zone data

A zone lookup tries four things in order:

  1. The Hive cache

    Keyed zone_<H3 cell in hex>. A hit is used immediately.

  2. The Cloudflare Worker

    With a 10-second timeout. A hit is cached.

  3. The expired cache entry

    Used if the network failed.

  4. The dark-sky default

    Zone 1, no artificial light, sky brightness 22.0, if the cell is nowhere, because the database holds only lit cells.

Zone data is static satellite data, so entries are not refreshed like weather. They expire after 365 days so a new year's data can replace them.

Weather

Weather entries are keyed by H3 cell and date, for example weather_<cell>_<YYYY-MM-DD> and weather_<cell>_daily_<YYYY-MM-DD>, with the date taken at midnight UTC so the key does not depend on the time zone.

  • Fresh means fetched in the last 24 hours. A fresh entry is returned without a network call.
  • Stale but safe. An older entry is returned only if the network fails, flagged as stale, and it is kept until a replacement succeeds.
  • Pruning. On app start and on resume, entries older than 09:00 local time today are deleted, without blocking the screen.

Saving a location

Saving a place fetches and caches, concurrently and silently: the current weather, the hourly forecast, the seven-day forecast and the zone. Several places are processed one at a time with a pause of 300 ms between them. No error is shown if a fetch fails.

Background sync

A periodic job runs every 24 hours on Android through WorkManager, and on iOS through the same plugin's background task scheduler (skipped on the simulator, which throws a native exception). It needs a network connection and a battery that is not low.

It refreshes the weather and the zone for the active locations: pinned locations, which always count and go first, and any location last viewed less than 11 days ago. Requests are 500 ms apart to be gentle on the battery, and every error is swallowed.

Computed on the device

Positions, rise and set times, moon phase and the night window need no network (see Planets and the sky). The sky state and the graphs are functions of cached weather and zone data, so they work offline as far as those inputs are cached.

Not built yet

  • Full zone download. The Worker has a /download route that streams the 716 MB zones.db from R2, and the Worker's README describes a Settings download that then looks cells up locally by binary search. Nothing in the app calls it today, so a new place with no network gets the dark-sky default.
  • Offline weather beyond the cache. There is no model that predicts weather offline, so the forecast is only as old as the last fetch.
  • Object packs. The plan is a small bundled core plus downloadable country or region packs for faint stars and deep-sky objects, versioned and checked by hash. See the overview.
  • Smaller zone packs. 716 MB is too large to bundle. Regional packs of the same cells are a likely replacement for a single global file.

Implementation

Excerpts of the real files, cut out by name, copied into the site and checked against the repository on every build.

zone_cache_entry.dart · isExpired
/// Zone data is static (satellite imagery from specific date)./// Unlike weather, we don't consider zone cache "stale" - it's valid until/// we regenerate zones.db with newer satellite data./// /// However, we may want to refresh after major app updates.bool get isExpired {  // Expire after 1 year to allow for data updates  return DateTime.now().toUtc().difference(fetchedAt).inDays > 365;}
cached_zone_repository.dart · getZoneData
/// Get zone data for an H3 index, using cache-first strategy.////// Returns:/// - [ZoneData] from cache or remote API/// - [pristineDarkSky] if not found in database (dark sky location!)Future<ZoneData> getZoneData(BigInt h3Index) async {  final String h3Hex = h3Index.toRadixString(16);  final String cacheKey = '$_keyPrefix$h3Hex';  // 1. Check cache first  final ZoneCacheEntry? cached = _cache.get(cacheKey);  if (cached != null && !cached.isExpired) {    debugPrint('Zone cache hit for $h3Hex');    return ZoneData(      astrZone: cached.astrZone,      ratio: cached.ratio,      sqm: cached.sqm,    );  }  // 2. Cache miss — fetch from remote D1 API  debugPrint('Zone cache miss for $h3Hex, fetching from remote...');  final ZoneData? remoteData = await _remote.getZoneData(h3Index);  if (remoteData != null) {    _cacheZoneData(cacheKey, h3Hex, remoteData);    return remoteData;  }  // 3. Remote failed - check if we have stale cache  if (cached != null) {    debugPrint('Remote failed, using expired cache for $h3Hex');    return ZoneData(      astrZone: cached.astrZone,      ratio: cached.ratio,      sqm: cached.sqm,    );  }  // 4. Not in database = pristine dark sky location  debugPrint('$h3Hex not in lit-areas DB, returning pristine dark sky');  return pristineDarkSky;}