diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt index 768cad88..40784907 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt @@ -51,7 +51,20 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor val statuses = buildStatuses(names, reports, nowSec) val reportMap = reports.associate { it.id to toSatReport(it) } - SatStatusPage(System.currentTimeMillis(), statuses, reportMap) + + // The summary endpoint tells us how many reports each satellite actually has, + // independent of the 500-record cap. Mark any satellite whose global pull is + // incomplete so the UI can show a data-coverage note. + val summaryJson = remoteSource.getAmSatSummary(hours = 72) + val expectedCounts = parseSummary(summaryJson) + val marked = statuses.map { status -> + val expected = expectedCounts[status.name] + val actual = status.days.sumOf { day -> day.slots.sumOf { it.count } } + if (expected != null && expected > actual) status.copy(summaryCount = expected) + else status + } + + SatStatusPage(System.currentTimeMillis(), marked, reportMap) } /** Parse catalog JSON to list of satellite names */ @@ -85,6 +98,33 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor } } + /** + * Parse summary JSON to per-satellite report counts. + * + * The summary aggregates across all statuses, so a satellite with both "heard" and + * "not heard" entries appears once; we sum its report_count across all its rows. + * Returns an empty map (not null) on failure so the caller can just check for + * missing keys — a failed summary call degrades gracefully to "no coverage marker". + */ + private fun parseSummary(json: String?): Map { + if (json == null) return emptyMap() + return try { + val arr = JSONObject(json).getJSONArray("data") + val out = mutableMapOf() + for (i in 0 until arr.length()) { + val o = arr.getJSONObject(i) + val name = o.optString("name", "") + val count = o.optInt("report_count", 0) + if (name.isNotEmpty() && count > 0) { + out[name] = (out[name] ?: 0) + count + } + } + out + } catch (_: Exception) { + emptyMap() + } + } + /** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */ private fun parseIsoUtcSec(iso: String): Long { return try { @@ -128,7 +168,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor // Oldest report across the whole response, marking how far back the data reaches. // Taken globally rather than per satellite: a quiet satellite has no reports of its // own, but the slots it shares with the rest of the response were still covered. - val dataFromSec = reports.minOfOrNull { it.reportedTimeUtcSec } ?: todayMidnightSec + // + // Timestamps of zero are excluded: parseIsoUtcSec returns 0 when a reported_time + // fails to parse, and a single such record would drag this back to 1970 and mark + // nothing as uncovered, silently reverting the distinction. + val dataFromSec = reports.asSequence() + .map { it.reportedTimeUtcSec } + .filter { it > 0L } + .minOrNull() + ?: todayMidnightSec return names.map { name -> val satReports = byName[name].orEmpty() diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt index 1ead6069..fdb0a665 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt @@ -101,4 +101,22 @@ class RemoteSource( null } } + + override suspend fun getAmSatSummary(hours: Int): String? = withContext(dispatcher) { + try { + val request = Request.Builder() + .url("https://www.amsat.org/status/api/v1/summary.php?hours=$hours") + .header("User-Agent", "Look4Sat/4.5.5") + .build() + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@use null + response.body?.string() + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + println("RemoteSource amsat summary exception: $exception") + null + } + } } diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatAuditTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatAuditTest.kt index 1a7bc1e4..4f6fbe91 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatAuditTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatAuditTest.kt @@ -22,6 +22,7 @@ class AmSatAuditTest { override suspend fun getNetworkStream(url: String): InputStream? = null override suspend fun getAmSatCatalog(): String? = null override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null + override suspend fun getAmSatSummary(hours: Int): String? = null } private val repo = AmSatRepository(UnusedSource) diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatSlotBuildTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatSlotBuildTest.kt index 6971f864..4431fde4 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatSlotBuildTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/AmSatSlotBuildTest.kt @@ -28,6 +28,7 @@ class AmSatSlotBuildTest { override suspend fun getNetworkStream(url: String): InputStream? = null override suspend fun getAmSatCatalog(): String? = null override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null + override suspend fun getAmSatSummary(hours: Int): String? = null } private val repo = AmSatRepository(UnusedSource) @@ -319,6 +320,39 @@ class AmSatSlotBuildTest { ) } + /** + * A report whose timestamp failed to parse must not disable the distinction. + * + * parseIsoUtcSec returns 0 for an unparseable reported_time, and coverage is the + * minimum timestamp in the response - so one such record would put the coverage + * boundary in 1970 and mark every slot as reported-on. Measured on a grid that should + * have had 18 no-data cells, a single zero timestamp took it to none. + */ + @Test + fun `a report with an unparseable timestamp does not disable the no-data marking`() { + val noData = 0xFFE8E8E8 + val realReport = report("AO-91", "heard", utc(2026, 8, 21, 12), id = "real") + val brokenTimestamp = ApiReport( + id = "broken", + name = "AO-91", + callsign = "TEST", + report = "heard", + gridSquare = "AA00", + reportedTimeUtcSec = 0L + ) + + val withoutBroken = build(listOf("AO-91"), listOf(realReport)) + .single().days.sumOf { day -> day.slots.count { it.statusColor == noData } } + val withBroken = build(listOf("AO-91"), listOf(realReport, brokenTimestamp)) + .single().days.sumOf { day -> day.slots.count { it.statusColor == noData } } + + assertTrue("the baseline must have uncovered slots to compare", withoutBroken > 0) + assertEquals( + "a zero timestamp must not change what counts as covered", + withoutBroken, withBroken + ) + } + @Test fun `an empty response marks nothing as covered`() { // With no reports at all there is no evidence about any slot. diff --git a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt index f8867d03..58194d23 100644 --- a/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt +++ b/core/data/src/test/java/com/rtbishop/look4sat/core/data/repository/DatabaseRepoTest.kt @@ -129,6 +129,8 @@ private class FakeRemoteSource : IRemoteSource { override suspend fun getAmSatCatalog(): String? = null override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null + + override suspend fun getAmSatSummary(hours: Int): String? = null } private class FakeLocalSource : ILocalSource { diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt index b941ba34..83ab702b 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/SatStatus.kt @@ -26,7 +26,8 @@ data class SatDay( /** One satellite, 3 days of state */ data class SatStatus( val name: String, // "AO-123_[FM]" - val days: List // 3 天(新→旧) + val days: List, // 3 天(新→旧) + val summaryCount: Int = 0 // 0 means unknown; used for data-completeness marking ) /** Overall page parse result */ diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt index e2271594..ee522b5b 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt @@ -28,4 +28,9 @@ interface IRemoteSource { /** Fetch AMSAT API reports for the past N hours (JSON string; null on failure) */ suspend fun getAmSatReports(hours: Int, limit: Int): String? + + /** Fetch AMSAT API summary for the past N hours (JSON string; null on failure). + * Used to compare against the global reports response and flag satellites whose data + * was crowded out of the 500-record cap. */ + suspend fun getAmSatSummary(hours: Int): String? } diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt index e958490f..eaa441e3 100644 --- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt @@ -251,6 +251,21 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f).padding(end = 4.dp) ) + // When the global report pull is incomplete, show how many we actually got + // versus what the summary endpoint says exists. The summary is a single extra + // request, so this is honest without the 88-request cost of per-satellite pulls. + if (status.summaryCount > 0) { + val actual = status.days.sumOf { day -> day.slots.sumOf { it.count } } + if (actual < status.summaryCount) { + Text( + text = "$actual / ${status.summaryCount}", + fontSize = 11.sp, + color = Color(0xFF888888), + maxLines = 1, + modifier = Modifier.padding(end = 8.dp) + ) + } + } status.days.forEach { day -> DayCell( day = day,