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
| Data | Offline? | How |
|---|---|---|
| Zone (light pollution) for places you have viewed or saved | yes | Hive cache, one entry per H3 cell, valid for a year |
| Weather for places you have viewed or saved | yes, possibly stale | Hive cache of hourly and seven-day forecasts |
| Sun, Moon and planet positions, rise and set | yes | computed on the device |
| Sky state and graphs | yes, from cached inputs | pure functions of the cached data |
| Zone for a new place with no network | dark-sky default | zone 1 is assumed, not measured |
| Object catalogue | yes, but tiny today | bundled SQLite file with 3 deep-sky objects and 4 stars |
| The full zone database | not built | see below |
Zone data
A zone lookup tries four things in order:
- The Hive cache
Keyed
zone_<H3 cell in hex>. A hit is used immediately. - The Cloudflare Worker
With a 10-second timeout. A hit is cached.
- The expired cache entry
Used if the network failed.
- 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
/downloadroute that streams the 716 MBzones.dbfrom 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 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;}/// 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;}/// Check if cache is stale (>24 hours old)./// Per FR-09: Retain expired data until replacement succeeds.bool get isStale => DateTime.now().toUtc().difference(fetchedAt).inHours > 24;/// Generates a cache key from H3 index and date.////// Date is normalized to midnight UTC to ensure consistent keys/// regardless of timezone or time of day.static String generate(String h3Index, DateTime date) { final normalizedDate = DateTime.utc(date.year, date.month, date.day); final dateStr = _formatDate(normalizedDate); return 'weather_${h3Index}_$dateStr';}/// Generates a daily forecast cache key.////// Format: `weather_{h3Index}_daily_{YYYY-MM-DD}`/// Used for 7-day individual forecast caching (Story 3.2).static String generateDaily(String h3Index, DateTime date) { final normalizedDate = DateTime.utc(date.year, date.month, date.day); final dateStr = _formatDate(normalizedDate); return 'weather_${h3Index}_daily_$dateStr';}/// Prunes cache entries older than "Today 09:00 AM" (device local time).////// FR-10: System must prune weather data older than "Today 09:00 AM".////// Returns the number of entries deleted.Future<int> pruneOldEntries() async { if (_cache.isEmpty) return 0; final DateTime now = DateTime.now(); final DateTime threshold = DateTime( now.year, now.month, now.day, 9, // 09:00 AM ); int deletedCount = 0; final List<dynamic> keysToDelete = <dynamic>[]; // Collect keys to delete for (final dynamic key in _cache.keys) { final WeatherCacheEntry? entry = _cache.get(key); if (entry == null) continue; // Check if entry date is before threshold final DateTime entryDate = DateTime( entry.date.year, entry.date.month, entry.date.day, ); if (entryDate.isBefore(threshold)) { keysToDelete.add(key); } } // Delete in batch for (final dynamic key in keysToDelete) { try { await _cache.delete(key); deletedCount++; } catch (e, st) { debugPrint('Failed to delete cache entry $key: $e'); debugPrint('Stack trace: $st'); // Continue with other deletions } } if (deletedCount > 0) { debugPrint('WeatherCachePruningService: Pruned $deletedCount old entries'); } return deletedCount;}/// Prefetches all data types for a single location.////// Runs all fetches concurrently for speed. Each fetch independently/// populates the cache via [CachedWeatherRepository] and [CachedZoneRepository].////// Never throws — all errors are caught and logged silently.Future<void> prefetchForLocation(GeoLocation location) async { debugPrint('Prefetch: Starting for ${location.name ?? 'unnamed'} ' '(${location.latitude}, ${location.longitude})'); await Future.wait<void>(<Future<void>>[ _prefetchCurrentWeather(location), _prefetchHourlyForecast(location), _prefetchDailyForecast(location), _prefetchZoneData(location), ]); debugPrint('Prefetch: Completed for ${location.name ?? 'unnamed'}');}/// Syncs weather for all active (non-stale) locations.////// Active locations are:/// - Pinned locations (always active, bypass staleness)/// - Non-stale locations (lastViewed < 11 days ago)////// Returns the number of locations successfully synced./// Never throws - all errors are caught and logged (NFR-04).Future<int> syncActiveLocations() async { try { // Get all saved locations final Either<Failure, List<UserLocation>> result = await _locations.getAllLocations(); return await result.fold( (Failure failure) { // Silent failure - log but don't crash debugPrint('Background sync: Failed to load locations: $failure'); return 0; }, (List<UserLocation> locations) async { if (locations.isEmpty) { debugPrint('Background sync: No locations to sync'); return 0; } int syncedCount = 0; // Prioritize pinned locations first (most important) final List<UserLocation> sortedLocations = <UserLocation>[ ...locations.where((UserLocation loc) => loc.isPinned), ...locations.where((UserLocation loc) => !loc.isPinned), ]; for (final UserLocation location in sortedLocations) { // Skip stale locations (unless pinned) if (_shouldSkipLocation(location)) { debugPrint( 'Background sync: Skipping stale location ${location.name}'); continue; } // Attempt to sync weather for this location final bool success = await _syncLocation(location); if (success) syncedCount++; // Battery-conscious: Small delay between requests await Future<void>.delayed(const Duration(milliseconds: 500)); } debugPrint( 'Background sync: Synced $syncedCount/${locations.length} locations'); return syncedCount; }, ); } catch (e, st) { // NFR-04: Silent failure - log but don't crash debugPrint('Background sync: Unexpected error: $e'); debugPrint('Stack trace: $st'); return 0; }}/// Determines if a location should be skipped during sync.////// Skip if:/// - NOT pinned AND stale (> 11 days since last view)bool _shouldSkipLocation(UserLocation location) { // Pinned locations are NEVER skipped if (location.isPinned) return false; // Use the established staleness logic from Epic 2 return UserLocationsNotifier.isStale(location);}/// Registers background sync with WorkManager.////// Call this from main.dart during app initialization./// Safe to call multiple times - won't create duplicate tasks.Future<void> initializeBackgroundSync() async { // BGTaskScheduler throws a native NSException on the iOS Simulator // that cannot be caught by Dart try-catch, causing a crash (SIGABRT). // Skip registration entirely on the simulator. if (Platform.isIOS) { final IosDeviceInfo deviceInfo = await DeviceInfoPlugin().iosInfo; if (!deviceInfo.isPhysicalDevice) { debugPrint('Background sync: Skipping on iOS Simulator'); return; } } try { await Workmanager().initialize( callbackDispatcher, isInDebugMode: kDebugMode, ); // Schedule periodic background sync (once per day) await Workmanager().registerPeriodicTask( kWeatherSyncTaskName, kWeatherSyncTaskKey, frequency: const Duration(hours: 24), constraints: Constraints( networkType: NetworkType.connected, // Require network requiresBatteryNotLow: true, // Skip if battery critical ), existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, // Don't replace existing ); debugPrint('Background sync: WorkManager initialized'); } catch (e) { // Silent fail - background sync is best-effort debugPrint('Background sync: Failed to initialize WorkManager: $e'); }}