Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/Ankiquest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<LocalDate, Int>,
Expand All @@ -19,6 +26,7 @@ data class ReviewHeatmapData(
val daysStudied: Int,
val firstReviewDate: LocalDate?,
val dailyAverage: Double,
val protectedDays: Set<LocalDate> = emptySet(),
) {
val forecastEndDate: LocalDate get() = today.plusDays(ReviewHeatmap.FORECAST_DAYS)
}
Expand Down Expand Up @@ -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<LocalDate, Int>,
val protectedDays: Set<LocalDate>,
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<LocalDate, Int>()
val protectedDays = mutableSetOf<LocalDate>()
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<LocalDate, Int>,
scheduled: Map<LocalDate, Int>,
protectedDays: Set<LocalDate> = 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
}
Expand All @@ -145,6 +219,7 @@ object ReviewHeatmap {
daysStudied = history.size,
firstReviewDate = firstReviewDate,
dailyAverage = if (history.isNotEmpty()) totalReviews.toDouble() / history.size else 0.0,
protectedDays = kept,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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) {
Expand All @@ -50,3 +72,6 @@ class ReviewHeatmapViewModel(
}.also { refreshJob = it }
}
}

private suspend fun ankiquestDays(): ReviewHeatmap.ServerDays? =
if (Ankiquest.isConfigured()) ReviewHeatmap.serverDays(Ankiquest.profile()) else null
1 change: 1 addition & 0 deletions AnkiDroid/src/main/res/values-night/review_heatmap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@
<color name="review_heatmap_forecast_two">#627080</color>
<color name="review_heatmap_forecast_three">#8997A7</color>
<color name="review_heatmap_forecast_four">#BAC4CF</color>
<color name="review_heatmap_protected">#7DB8EC</color>
</resources>
4 changes: 3 additions & 1 deletion AnkiDroid/src/main/res/values/review_heatmap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@
<string name="review_heatmap_year">Year %1$s</string>
<string name="review_heatmap_scroll_hint">Swipe to explore the year. Tap a day for details.</string>
<string name="review_heatmap_forecast_hint">Gray squares show estimated review cards due over the next 90 days. Counts change as you study.</string>
<string name="review_heatmap_legend">Fewer reviews → More reviews · Gray = forecast</string>
<string name="review_heatmap_legend">Fewer reviews → More reviews · Gray = forecast · Blue outline = streak kept by AnkiQuest</string>
<string name="review_heatmap_day_description">%1$s. %2$s</string>
<string name="review_heatmap_today_description">%1$s. Today</string>
<string name="review_heatmap_forecast_unavailable">Forecast is available for the next 90 days.</string>
<string name="review_heatmap_protected_day">%1$s · AnkiQuest kept your streak</string>
<string name="review_heatmap_view_reviewed_cards">View reviewed cards</string>
<string name="review_heatmap_view_due_cards">View due cards</string>
<plurals name="review_heatmap_review_count">
Expand All @@ -46,4 +47,5 @@
<color name="review_heatmap_forecast_two">#ABB3BE</color>
<color name="review_heatmap_forecast_three">#7D8998</color>
<color name="review_heatmap_forecast_four">#546171</color>
<color name="review_heatmap_protected">#2F80C9</color>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -91,4 +93,104 @@ class ReviewHeatmapDataTest {
.toEpochSecond()
assertEquals(LocalDate.of(2023, 12, 31), ReviewHeatmap.studyDate(nextRollover, losAngeles))
}

private fun server(
reviews: Map<LocalDate, Int>,
protectedDays: Set<LocalDate> = 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())
}
}
Loading