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..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 @@ -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. */ +/** + * 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,91 @@ object ReviewHeatmap { .toLocalDate() .minusDays(1) + /** + * 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 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( 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 +219,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..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 @@ -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 @@ -13,8 +14,16 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import timber.log.Timber -/** 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, 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 serverDays: suspend () -> ReviewHeatmap.ServerDays? = ::ankiquestDays, 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 server = + try { + serverDays() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + Timber.i(error, "AnkiQuest days unavailable") + null + } + ensureActive() + if (server != null) { + mutableState.value = State.Loaded(ReviewHeatmap.withServerDays(data, server)) + } } catch (cancelled: CancellationException) { throw cancelled } catch (error: Exception) { @@ -50,3 +72,6 @@ class ReviewHeatmapViewModel( }.also { refreshJob = it } } } + +private suspend fun ankiquestDays(): ReviewHeatmap.ServerDays? = + if (Ankiquest.isConfigured()) ReviewHeatmap.serverDays(Ankiquest.profile()) else null 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..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 @@ -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,104 @@ class ReviewHeatmapDataTest { .toEpochSecond() 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) + val missed = today.minusDays(2) + + val alone = ReviewHeatmap.summarize(today, reviews, emptyMap()) + assertEquals(2, alone.currentStreak) + assertEquals(2, alone.longestStreak) + + 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) + 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 `the profile's heatmap gives each day's reviews, the frozen days and the last upload`() { + val profile = + JSONObject( + """ + {"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} + ]} + """, + ) + 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()) + } }