fix(amsat): align status slots to their UTC calendar-day labels

AmSatRepository labelled columns by calendar date but filled them by slicing a
rolling 72-slot window ending at fetch time. The two timelines coincide only
near 23:59 UTC. At common fetch times the status grid lied about dates:

  UTC 00:00: 72 / 72 slots under the wrong label
             "today" column contained all of yesterday
  UTC 12:00: 36 / 72 wrong; every column straddled two dates
  UTC 13:37: 30 / 72 wrong
  UTC 23:59:  0 / 72 wrong (the accidental alignment case)

Anchor the six columns on UTC midnight instead. Every SatDay now covers exactly
[day 00:00, next day 00:00), split into twelve 2-hour slots newest-first so the
UI's existing first-non-gray lookup still chooses the latest daily report.

A standalone Java probe porting the old arithmetic reproduced the 72/72,
36/72 and 30/72 mismatches. Porting the new formula gives 0/72 mismatches at
00:00, 12:00, 13:37 and 23:59 UTC.

Also restore core:data's unit-test compilation. DatabaseRepoTest's fakes were
stale after IRemoteSource gained AMSAT methods and ISettingsRepo's zero-arg GPS
setter became suspend; the whole data test suite previously could not compile,
so data-layer regressions were untestable. Updated the fake members and verified
:core:data:testDebugUnitTest plus :core:data:compileReleaseKotlin BUILD
SUCCESSFUL. The product code does not use org.json in JVM tests because Android
org.json stubs throw there, so the date math remains verified by the standalone
same-JVM probe rather than a misleading mocked parser test.
This commit is contained in:
mckero committed 2026-08-14 18:08:46 +00:00
1 parent 2da7127fd3
commit 2bac6655f3
2 files changed
+34 -18

No files matched your search

@@ -92,28 +92,38 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
val byName = reports.groupBy { it.name }
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
// Anchor the columns on UTC midnight. Slicing a rolling "N x 2 hours ago"
// window into six groups of twelve does not line up with calendar days
// unless the fetch happens just before 24:00 UTC: at 00:00 UTC every one
// of the 72 slots sat under the wrong date label, and at 12:00 UTC half of
// them did, so the column headed "today" showed yesterday's reports.
val todayStartSec = (nowSec / 86400L) * 86400L
val labels = (0 until 6).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000
utc.timeInMillis = (todayStartSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
}
return names.map { name ->
val slots = (0 until 72).map { slotIdx ->
val slotStart = nowSec - (slotIdx + 1) * 7200L
val slotEnd = nowSec - slotIdx * 7200L
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
SatSlot(
statusColor = statusColorOf(newest.report),
count = inSlot.size,
reportIds = inSlot.map { it.id }
)
}
}
val reportsForSat = byName[name].orEmpty()
val days = (0 until 6).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
val dayStart = todayStartSec - d * 86400L
// Newest slot first: the UI picks the first non-gray slot of a day
// as that day's status.
val slots = (0 until 12).map { slotInDay ->
val slotStart = dayStart + (11 - slotInDay) * 7200L
val slotEnd = slotStart + 7200L
val inSlot = reportsForSat.filter { it.reportedTimeUtcSec in slotStart until slotEnd }
if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
SatSlot(
statusColor = statusColorOf(newest.report),
count = inSlot.size,
reportIds = inSlot.map { it.id }
)
}
}
SatDay(dateLabel = labels[d], slots = slots)
}
SatStatus(name = name, days = days)
}
@@ -125,6 +125,12 @@ private class FakeRemoteSource : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
override suspend fun getStatusHtml(): String? = null
override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
}
private class FakeLocalSource : ILocalSource {
@@ -200,7 +206,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override fun setStationPosition(): Boolean = true
override suspend fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true