fix(amsat): distinguish slots we have no data for from slots nobody reported

Grey meant two different things. The API caps at 500 records however many hours
are requested: measured against the live endpoint, a 72-hour request returned 500
reports spanning only 49 hours, so the oldest 9.5 hours of the third day had no
data at all. Those cells were painted the same grey as "nobody reported", which
claimed knowledge we did not have - 352 of 3168 cells on a real page, a third of
the third day's column.

Slots entirely older than the earliest report in the response now use a lighter
grey. Coverage is judged from all reports 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, so it must read as "not heard" rather than "unknown".

The two greys are now in the legend, which previously listed only the four active
states. That matters more than it sounds: on the live page 81% of cells are
"nobody reported" and 11% are outside our data, so a user looking at a mostly-grey
row had no way to tell a dead satellite from a gap in what we fetched. The legend
chips use a solid dot, so the two greys stay distinguishable despite the 25%
alpha background. Strings added to all nine locales.

Three tests cover it: a day entirely before the data starts, a day straddling the
boundary, and an empty response marking nothing as covered.
This commit is contained in:
mckero committed 2026-08-22 10:27:44 +00:00
1 parent 79215e7623
commit 3612e662e7
12 files changed
+115 -2

No files matched your search

@@ -125,6 +125,11 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
utc.timeInMillis = (todayMidnightSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
}
// 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
return names.map { name ->
val satReports = byName[name].orEmpty()
val days = (0 until 3).map { dayIdx ->
@@ -135,7 +140,9 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
val slotEnd = slotStart + 7200L
val inSlot = satReports.filter { it.reportedTimeUtcSec in slotStart until slotEnd }
if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
// A slot entirely before the data starts is unknown, not silent.
val colour = if (slotEnd <= dataFromSec) NO_DATA_GRAY else NO_REPORT_GRAY
SatSlot(statusColor = colour, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
SatSlot(
@@ -184,5 +191,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
private const val NOT_HEARD_PINK = 0xFFDC267F
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
private const val NO_REPORT_GRAY = 0xFFC0C0C0
/**
* Slots older than the data we actually received.
*
* The API caps at 500 records however many hours are requested. Measured live: a
* 72-hour request returned 500 reports spanning only 49 hours, leaving the oldest
* 9.5 hours of the third day with no data at all. Painting those the same grey as
* "nobody reported" claimed knowledge we do not have, so they get a lighter shade.
*/
private const val NO_DATA_GRAY = 0xFFE8E8E8
}
}
@@ -256,4 +256,77 @@ class AmSatSlotBuildTest {
fun `an empty catalog yields no rows rather than a malformed grid`() {
assertTrue(build(emptyList(), emptyList()).isEmpty())
}
/**
* Slots older than the data we received must not claim nobody was listening.
*
* The API caps at 500 records however many hours are asked for. Measured live, a
* 72-hour request returned 500 reports covering only 49 hours, so the oldest 9.5 hours
* of the third day had no data at all - 352 of 3168 cells were painting "nobody heard
* it" over "we never looked".
*/
@Test
fun `slots before the data starts are marked no-data, not no-report`() {
// The only report is midday yesterday, so nothing older than that was covered.
val oldestReport = utc(2026, 8, 21, 12)
val status = build(
listOf("AO-91"),
listOf(report("AO-91", "heard", oldestReport, id = "only"))
).single()
val noReport = 0xFFC0C0C0
val noData = 0xFFE8E8E8
// The day before yesterday is entirely before the data begins.
assertTrue(
"every slot older than the data must read as no-data",
status.days[2].slots.all { it.statusColor == noData }
)
// Yesterday straddles it: bands after midday are covered, bands before are not.
val yesterday = status.days[1]
assertEquals("the report's own band", 1, yesterday.slots[5].count)
assertTrue(
"bands after the oldest report are covered, so silence there is real",
yesterday.slots.take(6).all { it.statusColor != noData }
)
assertTrue(
"the earliest band of yesterday is before any data",
yesterday.slots[11].statusColor == noData
)
// Today is entirely after the data starts, so its silence is genuine.
assertTrue(
"today's empty slots mean nobody reported",
status.days[0].slots.all { it.statusColor == noReport }
)
}
@Test
fun `coverage is judged from all reports, not one satellite's`() {
// A satellite nobody reported must not show as no-data for the whole grid: the
// slots were covered, that satellite simply was not heard.
val statuses = build(
listOf("LOUD", "QUIET"),
listOf(report("LOUD", "heard", utc(2026, 8, 20, 1), id = "early"))
)
val quiet = statuses.first { it.name == "QUIET" }
val noData = 0xFFE8E8E8
assertTrue(
"coverage reaches back to the earliest report of any satellite",
quiet.days.all { day -> day.slots.none { it.statusColor == noData } }
)
}
@Test
fun `an empty response marks nothing as covered`() {
// With no reports at all there is no evidence about any slot.
val status = build(listOf("AO-91"), emptyList()).single()
val noData = 0xFFE8E8E8
assertTrue(
"yesterday and earlier cannot be claimed as silent",
status.days.drop(1).all { day -> day.slots.all { it.statusColor == noData } }
)
}
}
@@ -171,4 +171,6 @@
<string name="prefs_other_switch_cw_tone_shift">Desplazar tonos CW fuera de rango</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW solo analiza 400-1200 Hz. Si está activo, un tono fuera de ese rango se traslada al rango antes de decodificar; un tono que ya está dentro no se modifica.</string>
<string name="amsat_no_report_legend">Sin informes</string>
<string name="amsat_no_data_legend">Sin datos</string>
</resources>
@@ -300,4 +300,6 @@
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
<string name="prefs_other_switch_cw_tone_shift">Geser nada CW di luar rentang</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW hanya menganalisis 400-1200 Hz. Saat aktif, nada di luar rentang itu dipindahkan ke dalamnya sebelum decoding; nada yang sudah di dalam tidak diubah.</string>
<string name="amsat_no_report_legend">Tidak ada laporan</string>
<string name="amsat_no_data_legend">Tidak ada data</string>
</resources>
@@ -300,4 +300,6 @@
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
<string name="prefs_other_switch_cw_tone_shift">Geser nada CW di luar rentang</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW hanya menganalisis 400-1200 Hz. Saat aktif, nada di luar rentang itu dipindahkan ke dalamnya sebelum decoding; nada yang sudah di dalam tidak diubah.</string>
<string name="amsat_no_report_legend">Tidak ada laporan</string>
<string name="amsat_no_data_legend">Tidak ada data</string>
</resources>
@@ -171,4 +171,6 @@
<string name="prefs_other_switch_cw_tone_shift">Сдвигать CW-тоны вне диапазона</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW анализирует только 400-1200 Гц. Если включено, тон вне этого диапазона переносится внутрь перед декодированием; тон внутри диапазона не изменяется.</string>
<string name="amsat_no_report_legend">Нет отчётов</string>
<string name="amsat_no_data_legend">Нет данных</string>
</resources>
@@ -171,4 +171,6 @@
<string name="prefs_other_switch_cw_tone_shift">පරාසයෙන් පිටත CW ස්වර මාරු කරන්න</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW විශ්ලේෂණය කරන්නේ 400-1200 Hz පමණි. සක්‍රීය විට, එම පරාසයෙන් පිටත ස්වරයක් විකේතනයට පෙර පරාසය තුළට ගෙන එයි; දැනටමත් පරාසය තුළ ඇති ස්වරයක් වෙනස් නොකරයි.</string>
<string name="amsat_no_report_legend">වාර්තා නැත</string>
<string name="amsat_no_data_legend">දත්ත නැත</string>
</resources>
@@ -312,4 +312,6 @@
<string name="prefs_other_switch_cw_tone_shift">Aralık dışı CW tonlarını kaydır</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW yalnızca 400-1200 Hz analiz eder. Açıkken bu aralığın dışındaki bir ton çözülmeden önce aralığa taşınır; aralıkta olan ton değiştirilmez.</string>
<string name="amsat_no_report_legend">Rapor yok</string>
<string name="amsat_no_data_legend">Veri yok</string>
</resources>
@@ -171,4 +171,6 @@
<string name="prefs_other_switch_cw_tone_shift">Зсувати CW-тони поза діапазоном</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW аналізує лише 400-1200 Гц. Якщо увімкнено, тон поза цим діапазоном переноситься в нього перед декодуванням; тон, що вже в діапазоні, не змінюється.</string>
<string name="amsat_no_report_legend">Немає звітів</string>
<string name="amsat_no_data_legend">Немає даних</string>
</resources>
@@ -302,4 +302,6 @@
<string name="prefs_other_compass_offset_elev">雷达罗盘偏移 (仰角)</string>
<string name="prefs_other_switch_cw_tone_shift">搬移超出范围的 CW 音调</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW 仅分析 400-1200 Hz。开启后,超出该范围的音调会先搬移到范围内再解码;已在范围内的音调不作处理。</string>
<string name="amsat_no_report_legend">无人上报</string>
<string name="amsat_no_data_legend">无数据</string>
</resources>
@@ -335,4 +335,6 @@
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
<string name="prefs_other_switch_cw_tone_shift">Shift out-of-range CW tones</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW only analyses 400-1200 Hz. When on, a tone outside that range is moved into it before decoding; a tone already inside is untouched.</string>
<string name="amsat_no_report_legend">No report</string>
<string name="amsat_no_data_legend">No data</string>
</resources>
@@ -167,11 +167,16 @@ private fun StatusHeader(fetchedAtUtcMs: Long, isRefreshing: Boolean, onRefresh:
/** Legend: FlowRow of colored chips — wraps to two lines on narrow screens, stays one line when wide. */
@Composable
private fun LegendRow() {
// The two greys are listed because most of a typical grid is grey: measured live, 81%
// of cells were "nobody reported" and 11% were outside the data we received. Without
// the distinction a mostly-grey row reads as a dead satellite.
val legend = listOf(
stringResource(id = R.string.amsat_active) to Color(0xFF648FFF),
stringResource(id = R.string.amsat_tlm) to Color(0xFFFFB000),
stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F),
stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100)
stringResource(id = R.string.amsat_conflict) to Color(0xFFFE6100),
stringResource(id = R.string.amsat_no_report_legend) to Color(0xFFC0C0C0),
stringResource(id = R.string.amsat_no_data_legend) to Color(0xFFE8E8E8)
)
FlowRow(
modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp),