From 2d46860f31d1ae6bf3746748ab935208110f36e3 Mon Sep 17 00:00:00 2001 From: hill Date: Thu, 29 Oct 2026 11:37:51 +0100 Subject: [PATCH 1/3] Count the days AnkiQuest protected in the heatmap's streaks The heatmap counted a streak from the collection's review days alone, so a day a streak freeze covered broke it, while the AnkiQuest widget, which counts the server's streak, carried on: a freeze on 28 October left the heatmap at 1 day against a longer streak on the widget. The heatmap now reads the frozen days from the AnkiQuest profile once the collection has been drawn, and counts them as the server does, keeping a streak without lengthening it. Such days have a blue outline and say so when tapped. Without AnkiQuest set up, or offline, the heatmap is as before. --- .../com/ichi2/anki/ankiquest/Ankiquest.kt | 3 ++ .../deckpicker/heatmap/ReviewHeatmapData.kt | 42 ++++++++++++++-- .../deckpicker/heatmap/ReviewHeatmapGrid.kt | 8 +++ .../heatmap/ReviewHeatmapViewModel.kt | 27 +++++++++- .../main/res/values-night/review_heatmap.xml | 1 + .../src/main/res/values/review_heatmap.xml | 4 +- .../heatmap/ReviewHeatmapDataTest.kt | 49 +++++++++++++++++++ 7 files changed, 127 insertions(+), 7 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/Ankiquest.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/Ankiquest.kt index 5d4eecae2909..8f51d7030eb8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/Ankiquest.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/Ankiquest.kt @@ -125,6 +125,9 @@ object Ankiquest : ChangeManager.Subscriber, Application.ActivityLifecycleCallba get("$url/api/profile/$user", token) } + /** Whether a server and a player are set, so that the profile can be asked for. */ + fun isConfigured(): Boolean = endpoint() != null + /** The configured player name, or the sync username when none is set. */ fun player(): String? = AnkiDroidApp diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapData.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapData.kt index 4e811f8f2c40..7d22cbc8550f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapData.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapData.kt @@ -3,12 +3,19 @@ package com.ichi2.anki.deckpicker.heatmap import com.ichi2.anki.libanki.Collection +import org.json.JSONObject import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.temporal.ChronoUnit -/** Local collection history: counts are answers, not distinct cards or server points. */ +/** + * Local collection history: counts are answers, not distinct cards or server points. + * + * [protectedDays] are days without reviews that AnkiQuest kept the streak through, with a + * streak freeze or because moving the clock east skipped them. Both streaks count them as + * AnkiQuest does: such a day keeps a streak going and does not lengthen it. + */ data class ReviewHeatmapData( val today: LocalDate, val reviews: Map, @@ -19,6 +26,7 @@ data class ReviewHeatmapData( val daysStudied: Int, val firstReviewDate: LocalDate?, val dailyAverage: Double, + val protectedDays: Set = emptySet(), ) { val forecastEndDate: LocalDate get() = today.plusDays(ReviewHeatmap.FORECAST_DAYS) } @@ -111,25 +119,48 @@ object ReviewHeatmap { .toLocalDate() .minusDays(1) + /** The same history, with the days AnkiQuest protected counted into the streaks. */ + fun withProtectedDays( + data: ReviewHeatmapData, + protectedDays: Set, + ): ReviewHeatmapData = summarize(data.today, data.reviews, data.scheduled, protectedDays) + + /** The days an AnkiQuest profile's heatmap marks as frozen. */ + fun protectedDays(profile: JSONObject): Set { + val cells = profile.optJSONArray("heatmap") ?: return emptySet() + return (0 until cells.length()) + .mapNotNull { cells.optJSONObject(it) } + .filter { it.optBoolean("frozen") } + .mapNotNull { runCatching { LocalDate.parse(it.optString("date")) }.getOrNull() } + .toSet() + } + internal fun summarize( today: LocalDate, reviews: Map, scheduled: Map, + protectedDays: Set = emptySet(), ): ReviewHeatmapData { val history = reviews.filter { (date, count) -> date <= today && count > 0 }.toSortedMap() val forecastEnd = today.plusDays(FORECAST_DAYS) val forecast = scheduled.filter { (date, count) -> date > today && date <= forecastEnd && count > 0 }.toSortedMap() + val kept = protectedDays.filter { it < today && !history.containsKey(it) }.toSortedSet() var currentStreak = 0 var day = if (history.containsKey(today)) today else today.minusDays(1) - while (history.containsKey(day)) { - currentStreak++ + while (history.containsKey(day) || day in kept) { + if (history.containsKey(day)) currentStreak++ day = day.minusDays(1) } var longestStreak = 0 var run = 0 var previousDay: LocalDate? = null - for (studiedDay in history.keys) { - run = if (previousDay?.plusDays(1) == studiedDay) run + 1 else 1 + for (studiedDay in (history.keys + kept).sorted()) { + val continues = previousDay?.plusDays(1) == studiedDay + if (studiedDay in kept) { + if (!continues) run = 0 + } else { + run = if (continues) run + 1 else 1 + } longestStreak = maxOf(longestStreak, run) previousDay = studiedDay } @@ -145,6 +176,7 @@ object ReviewHeatmap { daysStudied = history.size, firstReviewDate = firstReviewDate, dailyAverage = if (history.isNotEmpty()) totalReviews.toDouble() / history.size else 0.0, + protectedDays = kept, ) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapGrid.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapGrid.kt index b0d57a72e4c6..6fa74e13eea4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapGrid.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapGrid.kt @@ -45,6 +45,7 @@ class ReviewHeatmapGrid private val cellPaint = Paint(Paint.ANTI_ALIAS_FLAG) private val outlineColor = MaterialColors.getColor(context, com.google.android.material.R.attr.colorOnSurface, 0) private val selectedColor = MaterialColors.getColor(context, androidx.appcompat.R.attr.colorPrimary, 0) + private val protectedColor = ContextCompat.getColor(context, R.color.review_heatmap_protected) private val reviewColors = intArrayOf( R.color.review_heatmap_empty, @@ -141,6 +142,12 @@ class ReviewHeatmapGrid cellPaint.alpha = if (date > currentData.forecastEndDate) 70 else 255 canvas.drawRoundRect(bounds, 3 * density, 3 * density, cellPaint) cellPaint.alpha = 255 + if (date in currentData.protectedDays) { + cellPaint.style = Paint.Style.STROKE + cellPaint.strokeWidth = 2 * density + cellPaint.color = protectedColor + canvas.drawRoundRect(bounds, 3 * density, 3 * density, cellPaint) + } if (date == selectedDate || date == currentData.today || accessibility.keyboardFocusedVirtualViewId == index) { cellPaint.style = Paint.Style.STROKE cellPaint.strokeWidth = 2 * density @@ -365,6 +372,7 @@ internal fun ReviewHeatmapData.dayCountDescription( return when { date > forecastEndDate -> context.getString(R.string.review_heatmap_forecast_unavailable) date > today -> due() + date in protectedDays -> context.getString(R.string.review_heatmap_protected_day, reviews()) else -> reviews() } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapViewModel.kt index 54ad349456ea..98f29a5d02a6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapViewModel.kt @@ -5,6 +5,7 @@ package com.ichi2.anki.deckpicker.heatmap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.ankiquest.Ankiquest import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.ensureActive @@ -12,9 +13,17 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import timber.log.Timber +import java.time.LocalDate -/** Keeps review history out of the deck-list loading path and survives activity recreation. */ +/** + * Keeps review history out of the deck-list loading path and survives activity recreation. + * + * The collection's history is shown as soon as it is read. When AnkiQuest is set up, the + * days its streak was protected on follow from the server, and the streaks are counted + * again with them; without a connection the heatmap stays as the collection alone says. + */ class ReviewHeatmapViewModel( + private val protectedDays: suspend () -> Set = ::ankiquestProtectedDays, private val load: suspend () -> ReviewHeatmapData = { withCol { ReviewHeatmap.load(this) } }, ) : ViewModel() { sealed interface State { @@ -40,6 +49,19 @@ class ReviewHeatmapViewModel( val data = load() ensureActive() mutableState.value = State.Loaded(data) + val kept = + try { + protectedDays() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + Timber.i(error, "AnkiQuest streak days unavailable") + emptySet() + } + ensureActive() + if (kept.isNotEmpty()) { + mutableState.value = State.Loaded(ReviewHeatmap.withProtectedDays(data, kept)) + } } catch (cancelled: CancellationException) { throw cancelled } catch (error: Exception) { @@ -50,3 +72,6 @@ class ReviewHeatmapViewModel( }.also { refreshJob = it } } } + +private suspend fun ankiquestProtectedDays(): Set = + if (Ankiquest.isConfigured()) ReviewHeatmap.protectedDays(Ankiquest.profile()) else emptySet() diff --git a/AnkiDroid/src/main/res/values-night/review_heatmap.xml b/AnkiDroid/src/main/res/values-night/review_heatmap.xml index 3ef5ed4e09ae..15acf95224cb 100644 --- a/AnkiDroid/src/main/res/values-night/review_heatmap.xml +++ b/AnkiDroid/src/main/res/values-night/review_heatmap.xml @@ -10,4 +10,5 @@ #627080 #8997A7 #BAC4CF + #7DB8EC diff --git a/AnkiDroid/src/main/res/values/review_heatmap.xml b/AnkiDroid/src/main/res/values/review_heatmap.xml index 30259263f2ad..91c95bccc268 100644 --- a/AnkiDroid/src/main/res/values/review_heatmap.xml +++ b/AnkiDroid/src/main/res/values/review_heatmap.xml @@ -19,10 +19,11 @@ Year %1$s Swipe to explore the year. Tap a day for details. Gray squares show estimated review cards due over the next 90 days. Counts change as you study. - Fewer reviews → More reviews · Gray = forecast + Fewer reviews → More reviews · Gray = forecast · Blue outline = streak kept by AnkiQuest %1$s. %2$s %1$s. Today Forecast is available for the next 90 days. + %1$s · AnkiQuest kept your streak View reviewed cards View due cards @@ -46,4 +47,5 @@ #ABB3BE #7D8998 #546171 + #2F80C9 diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapDataTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapDataTest.kt index 2c460886ab85..a2064e5a0569 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapDataTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapDataTest.kt @@ -2,11 +2,13 @@ package com.ichi2.anki.deckpicker.heatmap +import org.json.JSONObject import org.junit.Test import java.time.LocalDate import java.time.ZoneId import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue class ReviewHeatmapDataTest { private val today = LocalDate.of(2024, 3, 1) @@ -91,4 +93,51 @@ class ReviewHeatmapDataTest { .toEpochSecond() assertEquals(LocalDate.of(2023, 12, 31), ReviewHeatmap.studyDate(nextRollover, losAngeles)) } + + @Test + fun `a day AnkiQuest protected keeps the streak without lengthening it`() { + val reviews = mapOf(today to 5, today.minusDays(1) to 5, today.minusDays(3) to 5, today.minusDays(4) to 5) + val missed = today.minusDays(2) + + val alone = ReviewHeatmap.summarize(today, reviews, emptyMap()) + assertEquals(2, alone.currentStreak) + assertEquals(2, alone.longestStreak) + + val kept = ReviewHeatmap.withProtectedDays(alone, setOf(missed)) + assertEquals(4, kept.currentStreak) + assertEquals(4, kept.longestStreak) + assertEquals(setOf(missed), kept.protectedDays) + assertEquals(4, kept.daysStudied) + } + + @Test + fun `a protected day after a gap starts nothing, and a studied or future day is not protected`() { + val reviews = mapOf(today.minusDays(1) to 5) + val data = + ReviewHeatmap.summarize( + today, + reviews, + emptyMap(), + setOf(today.minusDays(5), today.minusDays(1), today), + ) + assertEquals(1, data.currentStreak) + assertEquals(1, data.longestStreak) + assertEquals(setOf(today.minusDays(5)), data.protectedDays) + } + + @Test + fun `protected days are read off the profile's frozen heatmap cells`() { + val profile = + JSONObject( + """ + {"heatmap": [ + {"date": "2026-10-27", "reviews": 120, "xp": 300, "frozen": false}, + {"date": "2026-10-28", "reviews": 0, "xp": 0, "frozen": true}, + {"date": "not a date", "reviews": 0, "xp": 0, "frozen": true} + ]} + """, + ) + assertEquals(setOf(LocalDate.of(2026, 10, 28)), ReviewHeatmap.protectedDays(profile)) + assertTrue(ReviewHeatmap.protectedDays(JSONObject("{}")).isEmpty()) + } } From 3df82682ee19cb40ddba058691ec52d974a791c0 Mon Sep 17 00:00:00 2001 From: hill Date: Thu, 29 Oct 2026 11:47:10 +0100 Subject: [PATCH 2/3] Number the heatmap's days by the clock each review was studied under The revlog records when a review happened but not under which clock, so the heatmap numbered every day by the device's present one. Reviews studied on a flight through time zones landed on a neighbouring day and left an empty one, which broke the heatmap's streak while AnkiQuest's carried on. AnkiQuest records each clock as it is reported and numbers reviews by the one in force, so its per-day counts are now used from the first day it has reviews for up to the day before the last review it was sent. Today and the days the server has not been sent stay the collection's. --- .../deckpicker/heatmap/ReviewHeatmapData.kt | 67 +++++++++++++++---- .../heatmap/ReviewHeatmapViewModel.kt | 26 +++---- .../heatmap/ReviewHeatmapDataTest.kt | 63 +++++++++++++++-- 3 files changed, 124 insertions(+), 32 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapData.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapData.kt index 7d22cbc8550f..d080cda99a6a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapData.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapData.kt @@ -10,7 +10,7 @@ import java.time.ZoneId import java.time.temporal.ChronoUnit /** - * Local collection history: counts are answers, not distinct cards or server points. + * Collection history: counts are answers, not distinct cards or server points. * * [protectedDays] are days without reviews that AnkiQuest kept the streak through, with a * streak freeze or because moving the clock east skipped them. Both streaks count them as @@ -119,20 +119,59 @@ object ReviewHeatmap { .toLocalDate() .minusDays(1) - /** The same history, with the days AnkiQuest protected counted into the streaks. */ - fun withProtectedDays( - data: ReviewHeatmapData, - protectedDays: Set, - ): ReviewHeatmapData = summarize(data.today, data.reviews, data.scheduled, protectedDays) + /** + * What an AnkiQuest profile's heatmap says of each day: its reviews, and whether it was + * frozen. [uploadedUntil] is the time of the last review the server has, whose id is the + * millisecond it was made at. + */ + data class ServerDays( + val reviews: Map, + val protectedDays: Set, + val uploadedUntil: Instant? = null, + ) + + fun serverDays(profile: JSONObject): ServerDays { + val last = profile.optLong("last_review_id").takeIf { it > 0 }?.let(Instant::ofEpochMilli) + val cells = profile.optJSONArray("heatmap") ?: return ServerDays(emptyMap(), emptySet(), last) + val reviews = mutableMapOf() + val protectedDays = mutableSetOf() + for (i in 0 until cells.length()) { + val cell = cells.optJSONObject(i) ?: continue + val date = runCatching { LocalDate.parse(cell.optString("date")) }.getOrNull() ?: continue + reviews[date] = cell.optInt("reviews") + if (cell.optBoolean("frozen")) protectedDays += date + } + return ServerDays(reviews, protectedDays, last) + } - /** The days an AnkiQuest profile's heatmap marks as frozen. */ - fun protectedDays(profile: JSONObject): Set { - val cells = profile.optJSONArray("heatmap") ?: return emptySet() - return (0 until cells.length()) - .mapNotNull { cells.optJSONObject(it) } - .filter { it.optBoolean("frozen") } - .mapNotNull { runCatching { LocalDate.parse(it.optString("date")) }.getOrNull() } - .toSet() + /** + * The same history with AnkiQuest's numbering of the days it holds. + * + * The revlog records when a review happened but not under which clock, so the + * collection can only number its days by the clock the device has now: a flight + * through time zones moves the reviews studied on the plane onto a neighbouring + * day and leaves an empty one. AnkiQuest records each clock as it is reported and + * numbers every review by the one in force when it was made. Its counts are used + * from the first day it has reviews for up to the day before the last review it was + * sent, and never for today: those days may hold reviews not uploaded yet, so they + * stay the collection's, as do days the server does not cover. + */ + fun withServerDays( + data: ReviewHeatmapData, + server: ServerDays, + zone: ZoneId = ZoneId.systemDefault(), + ): ReviewHeatmapData { + val first = server.reviews.filterValues { it > 0 }.keys.minOrNull() + val uploaded = server.uploadedUntil?.atZone(zone)?.toLocalDate() ?: data.today + val until = minOf(data.today, uploaded) + val reviews = + if (first == null || first >= until) { + data.reviews + } else { + data.reviews.filterKeys { it < first || it >= until } + + server.reviews.filter { (date, count) -> date >= first && date < until && count > 0 } + } + return summarize(data.today, reviews, data.scheduled, server.protectedDays) } internal fun summarize( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapViewModel.kt index 98f29a5d02a6..48460b5fa360 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapViewModel.kt @@ -13,17 +13,17 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import timber.log.Timber -import java.time.LocalDate /** * Keeps review history out of the deck-list loading path and survives activity recreation. * - * The collection's history is shown as soon as it is read. When AnkiQuest is set up, the - * days its streak was protected on follow from the server, and the streaks are counted - * again with them; without a connection the heatmap stays as the collection alone says. + * The collection's history is shown as soon as it is read. When AnkiQuest is set up, its + * numbering of the days, which follows the clock each review was made under, and the days + * its streak was protected on follow from the server, and the heatmap is drawn again with + * them; without a connection it stays as the collection alone says. */ class ReviewHeatmapViewModel( - private val protectedDays: suspend () -> Set = ::ankiquestProtectedDays, + private val serverDays: suspend () -> ReviewHeatmap.ServerDays? = ::ankiquestDays, private val load: suspend () -> ReviewHeatmapData = { withCol { ReviewHeatmap.load(this) } }, ) : ViewModel() { sealed interface State { @@ -49,18 +49,18 @@ class ReviewHeatmapViewModel( val data = load() ensureActive() mutableState.value = State.Loaded(data) - val kept = + val server = try { - protectedDays() + serverDays() } catch (cancelled: CancellationException) { throw cancelled } catch (error: Exception) { - Timber.i(error, "AnkiQuest streak days unavailable") - emptySet() + Timber.i(error, "AnkiQuest days unavailable") + null } ensureActive() - if (kept.isNotEmpty()) { - mutableState.value = State.Loaded(ReviewHeatmap.withProtectedDays(data, kept)) + if (server != null) { + mutableState.value = State.Loaded(ReviewHeatmap.withServerDays(data, server)) } } catch (cancelled: CancellationException) { throw cancelled @@ -73,5 +73,5 @@ class ReviewHeatmapViewModel( } } -private suspend fun ankiquestProtectedDays(): Set = - if (Ankiquest.isConfigured()) ReviewHeatmap.protectedDays(Ankiquest.profile()) else emptySet() +private suspend fun ankiquestDays(): ReviewHeatmap.ServerDays? = + if (Ankiquest.isConfigured()) ReviewHeatmap.serverDays(Ankiquest.profile()) else null diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapDataTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapDataTest.kt index a2064e5a0569..fec12f9c5cb9 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapDataTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapDataTest.kt @@ -94,6 +94,56 @@ class ReviewHeatmapDataTest { assertEquals(LocalDate.of(2023, 12, 31), ReviewHeatmap.studyDate(nextRollover, losAngeles)) } + private fun server( + reviews: Map, + protectedDays: Set = emptySet(), + uploadedUntil: LocalDate = today, + ) = ReviewHeatmap.ServerDays( + reviews, + protectedDays, + uploadedUntil.atTime(12, 0).atZone(ZoneId.of("UTC")).toInstant(), + ) + + private fun withServer( + data: ReviewHeatmapData, + server: ReviewHeatmap.ServerDays, + ) = ReviewHeatmap.withServerDays(data, server, ZoneId.of("UTC")) + + @Test + fun `reviews studied on a flight count on the day AnkiQuest numbered them by`() { + val local = mapOf(today to 5, today.minusDays(1) to 0, today.minusDays(2) to 30, today.minusDays(3) to 8) + val alone = ReviewHeatmap.summarize(today, local, emptyMap()) + assertEquals(1, alone.currentStreak) + + val numbered = + withServer( + alone, + server(mapOf(today.minusDays(3) to 8, today.minusDays(2) to 10, today.minusDays(1) to 20, today to 2)), + ) + assertEquals(20, numbered.reviews[today.minusDays(1)]) + assertEquals(10, numbered.reviews[today.minusDays(2)]) + assertEquals(5, numbered.reviews[today], "today stays the collection's") + assertEquals(4, numbered.currentStreak) + assertEquals(43L, numbered.totalReviews) + } + + @Test + fun `days the server has not been sent, or never covered, stay the collection's`() { + val local = mapOf(today.minusDays(1) to 9, today.minusDays(2) to 7, today.minusDays(40) to 3) + val alone = ReviewHeatmap.summarize(today, local, emptyMap()) + val stale = + withServer( + alone, + server(mapOf(today.minusDays(2) to 7, today.minusDays(1) to 0), uploadedUntil = today.minusDays(1)), + ) + assertEquals(9, stale.reviews[today.minusDays(1)]) + assertEquals(3, stale.reviews[today.minusDays(40)]) + assertEquals(2, stale.currentStreak) + + val empty = withServer(alone, server(emptyMap())) + assertEquals(alone.reviews, empty.reviews) + } + @Test fun `a day AnkiQuest protected keeps the streak without lengthening it`() { val reviews = mapOf(today to 5, today.minusDays(1) to 5, today.minusDays(3) to 5, today.minusDays(4) to 5) @@ -103,7 +153,7 @@ class ReviewHeatmapDataTest { assertEquals(2, alone.currentStreak) assertEquals(2, alone.longestStreak) - val kept = ReviewHeatmap.withProtectedDays(alone, setOf(missed)) + val kept = withServer(alone, server(reviews.mapValues { 5 } + (missed to 0), setOf(missed))) assertEquals(4, kept.currentStreak) assertEquals(4, kept.longestStreak) assertEquals(setOf(missed), kept.protectedDays) @@ -126,18 +176,21 @@ class ReviewHeatmapDataTest { } @Test - fun `protected days are read off the profile's frozen heatmap cells`() { + fun `the profile's heatmap gives each day's reviews, the frozen days and the last upload`() { val profile = JSONObject( """ - {"heatmap": [ + {"last_review_id": 1793270000000, "heatmap": [ {"date": "2026-10-27", "reviews": 120, "xp": 300, "frozen": false}, {"date": "2026-10-28", "reviews": 0, "xp": 0, "frozen": true}, {"date": "not a date", "reviews": 0, "xp": 0, "frozen": true} ]} """, ) - assertEquals(setOf(LocalDate.of(2026, 10, 28)), ReviewHeatmap.protectedDays(profile)) - assertTrue(ReviewHeatmap.protectedDays(JSONObject("{}")).isEmpty()) + val days = ReviewHeatmap.serverDays(profile) + assertEquals(mapOf(LocalDate.of(2026, 10, 27) to 120, LocalDate.of(2026, 10, 28) to 0), days.reviews) + assertEquals(setOf(LocalDate.of(2026, 10, 28)), days.protectedDays) + assertEquals(1793270000000L, days.uploadedUntil?.toEpochMilli()) + assertTrue(ReviewHeatmap.serverDays(JSONObject("{}")).reviews.isEmpty()) } } From 3a4b99f05c933ef803925b899b404b2893ef4b89 Mon Sep 17 00:00:00 2001 From: hill Date: Thu, 29 Oct 2026 15:11:29 +0100 Subject: [PATCH 3/3] Wrap the server-days chain for ktlint --- .../com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapData.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapData.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapData.kt index d080cda99a6a..b0e7ee2bc8bf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapData.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapData.kt @@ -161,7 +161,11 @@ object ReviewHeatmap { server: ServerDays, zone: ZoneId = ZoneId.systemDefault(), ): ReviewHeatmapData { - val first = server.reviews.filterValues { it > 0 }.keys.minOrNull() + val first = + server.reviews + .filterValues { it > 0 } + .keys + .minOrNull() val uploaded = server.uploadedUntil?.atZone(zone)?.toLocalDate() ?: data.today val until = minOf(data.today, uploaded) val reviews =