diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt
index 2bcb27b..adc7b2c 100644
--- a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt
+++ b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt
@@ -68,6 +68,24 @@ data class RustServerDto(
/** When this module last wrote the row — a failed poll moves it too. */
val updatedAt: String? = null,
val stale: Boolean = false,
+ /**
+ * When the server wipes next (module-rust phase 16, D130), or null. Null
+ * both when the operator set no schedule and against a module older than
+ * phase 16, which does not send the field — either way nothing is drawn.
+ */
+ val nextWipe: RustNextWipeDto? = null,
+)
+
+/**
+ * The next wipe, as the module computed it from the operator's schedule on this
+ * read. [source] is `forced` (Facepunch's monthly forced wipe), `rule` (the
+ * server's own weekly or biweekly day) or `once` (a one-off date the operator
+ * set — a delayed or an extra wipe). An unknown word is shown like `rule`.
+ */
+@Serializable
+data class RustNextWipeDto(
+ val at: String? = null,
+ val source: String? = null,
)
/**
diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt
index aae292b..292202b 100644
--- a/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt
+++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt
@@ -67,6 +67,29 @@ fun wipeDay(
.format(at.atZone(zone))
}
+/**
+ * The next wipe (module-rust phase 16), as `Thu 1 Oct, 19:00 · in 6 days` in
+ * the PHONE's zone — or null when there is no instant to show.
+ *
+ * The module computes the instant from the operator's rule in the operator's
+ * zone; the phone only says it the reader's way, which is the same split the
+ * website's `nextWipe()` makes. The `(rescheduled)` mark for a one-off date is
+ * the caller's, from a string resource.
+ */
+fun nextWipeWhen(
+ value: String?,
+ now: Instant = Instant.now(),
+ zone: ZoneId = ZoneId.systemDefault(),
+ locale: Locale = Locale.getDefault(),
+): String? {
+ val at = parseWireInstant(value) ?: return null
+ val local = at.atZone(zone)
+ val date = DateTimeFormatter.ofPattern("EEE d MMM", locale).format(local)
+ val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale).format(local)
+ val distance = rustAgo(value, now) ?: return "$date, $time"
+ return "$date, $time · $distance"
+}
+
/**
* "3 minutes ago", for a "last reported" line.
*
diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt
index 522166b..95cc9d7 100644
--- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt
+++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt
@@ -186,6 +186,15 @@ private fun ServerHeader(
)
}
+ nextWipeLine(server)?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 2.dp),
+ )
+ }
+
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt
index 353b55e..42ed4d6 100644
--- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt
+++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt
@@ -142,6 +142,15 @@ private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) {
)
}
+ nextWipeLine(server)?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 2.dp),
+ )
+ }
+
Text(
text = lastReported(server),
style = MaterialTheme.typography.bodySmall,
@@ -170,6 +179,22 @@ internal fun describeWorld(server: RustServerDto): String? {
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
}
+/**
+ * "Next wipe Thu 1 Oct, 19:00 · in 6 days", with "(rescheduled)" for a one-off
+ * date — or null, and the caller draws no line, when the server has no schedule
+ * or the module predates phase 16 and sends no field.
+ */
+@Composable
+internal fun nextWipeLine(server: RustServerDto): String? {
+ val next = server.nextWipe ?: return null
+ val whenText = nextWipeWhen(next.at) ?: return null
+ return if (next.source == "once") {
+ stringResource(R.string.rust_next_wipe_rescheduled, whenText)
+ } else {
+ stringResource(R.string.rust_next_wipe, whenText)
+ }
+}
+
/**
* "last reported 3 minutes ago".
*
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f0e7676..590478d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -578,6 +578,8 @@
size %1$d
seed %1$d
wiped %1$s
+ Next wipe %1$s
+ Next wipe %1$s (rescheduled)
Could not refresh just now. This is the last thing the site heard.
No such server
This address does not name a server this site follows.
diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt
index 177f058..49803ce 100644
--- a/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt
+++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt
@@ -102,4 +102,23 @@ class RustDtoTest {
assertNull(event.str("name"))
assertEquals(FeedTone.SERVER, describe(event).tone)
}
+
+ // ── The next wipe (module-rust phase 16, M18) ──────────────────────────
+
+ @Test
+ fun `the next wipe decodes, and is null against a module that does not send it`() {
+ val list = json.decodeFromString(
+ """{"servers":[
+ {"id":"a","name":"A","nextWipe":{"at":"2026-10-01T18:00:00.000Z","source":"forced"}},
+ {"id":"b","name":"B","nextWipe":null},
+ {"id":"c","name":"C"}
+ ]}""",
+ )
+ val (a, b, c) = list.servers
+ assertEquals("2026-10-01T18:00:00.000Z", a.nextWipe?.at)
+ assertEquals("forced", a.nextWipe?.source)
+ // No schedule, and a module older than phase 16: the same answer, nothing drawn.
+ assertNull(b.nextWipe)
+ assertNull(c.nextWipe)
+ }
}
diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt
index 1f0525c..b11de19 100644
--- a/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt
+++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt
@@ -131,4 +131,26 @@ class RustFormatTest {
assertEquals("1234", shortSteamId("1234"))
assertEquals("", shortSteamId(null))
}
+
+ // ── The next wipe (module-rust phase 16, M18) ──────────────────────────
+
+ @Test
+ fun `the next wipe is said in the phone's zone with how far away it is`() {
+ val sept25 = Instant.parse("2026-09-25T12:00:00Z")
+ // The forced wipe: 18:00 UTC is 19:00 in London in summer, 13:00 in Chicago.
+ assertEquals(
+ "Thu 1 Oct, 19:00 · in 6 days",
+ nextWipeWhen("2026-10-01T18:00:00.000Z", sept25, ZoneId.of("Europe/London"), uk),
+ )
+ assertTrue(
+ nextWipeWhen("2026-10-01T18:00:00.000Z", sept25, ZoneId.of("America/Chicago"), Locale.US)!!
+ .startsWith("Thu 1 Oct, 1:00"),
+ )
+ }
+
+ @Test
+ fun `no instant is no line`() {
+ assertNull(nextWipeWhen(null, now, utc, uk))
+ assertNull(nextWipeWhen("soon", now, utc, uk))
+ }
}