diff --git a/.github/workflows/ankiquest_api_contract.yml b/.github/workflows/ankiquest_api_contract.yml new file mode 100644 index 000000000000..b9ea413a748d --- /dev/null +++ b/.github/workflows/ankiquest_api_contract.yml @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: AGPL-3.0-only +name: ankiquest API contract + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "17 5 * * *" + workflow_dispatch: + inputs: + server_ref: + description: "float3/ankiquest branch, tag or commit whose API examples to check against" + required: true + default: master + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + cancel-in-progress: true + +env: + JAVA_TOOL_OPTIONS: -Dkotlin.daemon.options=autoshutdownIdleSeconds=10 + +jobs: + # Everything from the server checkout runs here, in a job with no cache, so it + # can never reach the shared Gradle cache. The contract job gets the examples as data. + server: + name: Server examples + if: github.event_name != 'schedule' || github.repository == 'float3/AnkiQuest-Android' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: float3/ankiquest + ref: ${{ inputs.server_ref || 'master' }} + path: server + persist-credentials: false + + - name: Keep the server's current examples + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: server-api-examples + path: server/docs/api/examples/*.json + if-no-files-found: warn + retention-days: 1 + + contract: + name: API contract + needs: server + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download the server's current examples + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + continue-on-error: true + with: + name: server-api-examples + path: ${{ runner.temp }}/server-api-examples + + - name: Use the server's current examples + shell: bash + run: | + examples="$RUNNER_TEMP/server-api-examples" + resources=AnkiDroid/src/test/resources/ankiquest-api + if compgen -G "$examples/*.json" > /dev/null; then + find "$resources" -name '*.json' -delete + cp "$examples"/*.json "$resources"/ + git status --short -- "$resources" + else + echo "The server at this ref has no docs/api/examples; checking the committed snapshot" + fi + + - name: Configure JDK + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: "jetbrains" + java-version: "21" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + timeout-minutes: 10 + with: + cache-provider: basic + cache-read-only: ${{ github.ref != 'refs/heads/main' }} + + - name: Gradle Dependency Download + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + timeout_minutes: 10 + retry_wait_seconds: 60 + max_attempts: 3 + command: ./gradlew robolectricSdkDownload --daemon + + - name: Run the contract test + run: ./gradlew :AnkiDroid:testFullDebugUnitTest --tests 'com.ichi2.anki.ankiquest.AnkiquestApiContractTest' --daemon + + - name: Report Unit Test Results + if: always() && hashFiles('**/build/test-results/**/TEST-*.xml') != '' + run: python3 .github/report_test_failures.py . --pattern "build/test-results/**/TEST-*.xml" --title "API contract failures" diff --git a/.github/workflows/ankiquest_art_check.yml b/.github/workflows/ankiquest_art_check.yml new file mode 100644 index 000000000000..304c000ddb6d --- /dev/null +++ b/.github/workflows/ankiquest_art_check.yml @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: AGPL-3.0-only +name: ankiquest art check + +# Runs the server's art exporter against the app's drawables. It runs the server's code, so it +# only runs for pull requests and pushes, never on a schedule or by hand. +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + art: + name: App art matches the server's exporter + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: float3/ankiquest + ref: master + path: server + persist-credentials: false + + - name: Check the app's art against the server's exporter + shell: bash + run: | + if [ -f server/tools/export_android_art.py ]; then + python3 -m venv "$RUNNER_TEMP/art" + "$RUNNER_TEMP/art/bin/pip" install --quiet pillow + "$RUNNER_TEMP/art/bin/python" server/tools/export_android_art.py --check . + else + echo "The server has no tools/export_android_art.py" + fi diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestActivity.kt index 344a20e5d493..a168af3c2305 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestActivity.kt @@ -33,6 +33,7 @@ import com.ichi2.anki.preferences.PreferencesActivity import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch @@ -76,6 +77,7 @@ internal class AnkiquestBrowserSessionBridge { class AnkiquestActivity : AnkiActivity(R.layout.activity_ankiquest) { private lateinit var webView: WebView private var session: AnkiquestWebSession? = null + private var sessionCookie = false private var account = "" private var refreshAfterSettings = false private var clearHistoryAfterLoad = false @@ -202,7 +204,7 @@ class AnkiquestActivity : AnkiActivity(R.layout.activity_ankiquest) { clearHistoryAfterLoad = false back.isEnabled = view.canGoBack() } - current.script(url)?.let { view.evaluateJavascript(it, null) } + current.script(url, sessionCookie)?.let { view.evaluateJavascript(it, null) } } override fun doUpdateVisitedHistory( @@ -228,8 +230,11 @@ class AnkiquestActivity : AnkiActivity(R.layout.activity_ankiquest) { val expected = session ?: return val expectedAccount = account prepareSession?.cancel() + sessionCookie = false prepareSession = lifecycleScope.launch { + var installed = false + val features = async { AnkiquestHomeData.account()?.let { AnkiquestCapabilities.current(it) }.orEmpty() } val ready = sessionBridge.prepare( current = { @@ -247,13 +252,14 @@ class AnkiquestActivity : AnkiActivity(R.layout.activity_ankiquest) { null } }, - install = { cookie -> setSessionCookie(expected.dashboard, cookie) }, + install = { cookie -> setSessionCookie(expected.dashboard, cookie).also { installed = it } }, ) if (expected != session || expected != Ankiquest.webSession() || expectedAccount != account || expectedAccount != AnkiquestNavigation.accountFingerprint() ) { return@launch } + sessionCookie = ready && installed && AnkiquestCapabilities.COOKIE_SESSION in features.await() if (!ready) { findViewById(R.id.progress_bar).visibility = View.GONE val message = getString(R.string.ankiquest_web_prepare_failed).htmlEncode() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestCapabilities.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestCapabilities.kt new file mode 100644 index 000000000000..4c807e4ee792 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestCapabilities.kt @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import android.content.SharedPreferences +import androidx.annotation.VisibleForTesting +import androidx.core.content.edit +import com.ichi2.anki.AnkiDroidApp +import com.ichi2.anki.common.time.TimeManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap + +/** + * The optional features a server reports at `GET /api/capabilities`, so the app can use a feature as + * soon as the server it talks to has it, whatever order the app and the server are updated in. A + * server too old to answer reports nothing; while a server cannot be reached, the features it last + * reported still count. + */ +internal object AnkiquestCapabilities { + /** The server keeps a dashboard signed in by its HttpOnly cookie alone, without the token. */ + const val COOKIE_SESSION = "cookie_session" + + @VisibleForTesting + internal const val PREF_CAPABILITIES = "ankiquestCapabilities" + + @VisibleForTesting + internal const val MAX_AGE_MS = 6L * 60 * 60 * 1000 + + private data class Known( + val features: Set, + val fetchedAt: Long, + val scope: String, + ) + + private val known = ConcurrentHashMap() + + /** Whether [account]'s server last reported [feature], without asking it again. */ + fun has( + account: HomeAccount, + feature: String, + ): Boolean = feature in features(account) + + /** The features [account]'s server last reported, without asking it again. */ + fun features(account: HomeAccount): Set = lookup(account.server)?.features.orEmpty() + + /** + * The features of [account]'s server, asked again when they were last fetched over [MAX_AGE_MS] + * ago or for a different account. + */ + suspend fun current(account: HomeAccount): Set { + val last = lookup(account.server) + if (last != null && last.scope == account.scope && age(last) in 0 until MAX_AGE_MS) return last.features + return refresh(account) + } + + /** Asks [account]'s server for its features; when it cannot be reached, answers the ones it last reported. */ + suspend fun refresh(account: HomeAccount): Set = + withContext(Dispatchers.IO) { + val fetched = + try { + fetch(account) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.d(e, "ankiquest capabilities unavailable") + return@withContext features(account) + } + store(account, fetched) + fetched + } + + /** The features in a `/api/capabilities` answer; anything unreadable counts as none. */ + fun parse(body: String): Set = + try { + parse(JSONObject(body)) + } catch (_: JSONException) { + emptySet() + } + + private fun parse(answer: JSONObject): Set { + val features = answer.optJSONArray("features") ?: return emptySet() + return (0 until features.length()).mapNotNull { features.opt(it) as? String }.filter { it.isNotEmpty() }.toSet() + } + + private fun fetch(account: HomeAccount): Set { + val request = AnkiquestApi.request(account.url("api/capabilities"), "", account.language).build() + return try { + AnkiquestApi.call(request, AnkiquestApi.Timeout.QUICK) { parse(it.body.string()) } + } catch (e: AnkiquestHttpException) { + if (e.code == 404) emptySet() else throw e + } + } + + private fun age(entry: Known): Long = TimeManager.time.intTimeMS() - entry.fetchedAt + + private fun preferences(): SharedPreferences = AnkiDroidApp.sharedPrefs() + + private fun key(server: String) = scopedKey(PREF_CAPABILITIES, server) + + private fun lookup(server: String): Known? = known[server] ?: stored(server)?.also { known[server] = it } + + private fun stored(server: String): Known? = + try { + val value = JSONObject(preferences().getString(key(server), null) ?: return null) + Known(parse(value), value.optLong("at"), value.optString("scope")) + } catch (_: JSONException) { + null + } catch (_: ClassCastException) { + null + } + + private fun store( + account: HomeAccount, + features: Set, + ) { + val entry = Known(features, TimeManager.time.intTimeMS(), account.scope) + known[account.server] = entry + preferences().edit { + putString( + key(account.server), + JSONObject() + .put("features", JSONArray(features.sorted())) + .put("at", entry.fetchedAt) + .put("scope", entry.scope) + .toString(), + ) + } + } + + /** Forgets the features held in memory, as a restarted app would. */ + @VisibleForTesting + internal fun forgetMemory() = known.clear() +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestPoll.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestPoll.kt index ff451d6eff7f..de7d901ef6b0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestPoll.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestPoll.kt @@ -25,7 +25,8 @@ import java.util.concurrent.TimeUnit /** * Uploads pending reviews and fetches the leaderboard and profile in the background, redraws the - * widget and raises rank and streak notifications. Runs every 15 minutes and on demand. + * widget and raises rank and streak notifications, and keeps the server's capabilities current. + * Runs every 15 minutes and on demand. */ object AnkiquestPoll { private const val PERIODIC_NAME = "ankiquestPoll" @@ -158,6 +159,7 @@ object AnkiquestPoll { Timber.d(e, "ankiquest optional photos could not refresh") } } + AnkiquestHomeData.account(prefs)?.let { AnkiquestCapabilities.current(it) } return result } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWebSession.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWebSession.kt index 8e3d5e78c542..0becf8914686 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWebSession.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ankiquest/AnkiquestWebSession.kt @@ -88,8 +88,15 @@ internal data class AnkiquestWebSession( actual: HttpUrl, ): String? = actual.encodedPath.takeIf { it.startsWith(expected.encodedPath) }?.removePrefix(expected.encodedPath) - /** The page script; a navigation can finish after the native check, so it checks the actual document again. */ - fun script(url: String?): String? { + /** + * The page script; a navigation can finish after the native check, so it checks the actual document again. + * With [cookie], the HttpOnly session cookie is installed and the server reports + * [AnkiquestCapabilities.COOKIE_SESSION], so the page learns only who is signed in and never sees the token. + */ + fun script( + url: String?, + cookie: Boolean = false, + ): String? { if (!allows(url)) return null val expected = checkNotNull(base) val root = expected.encodedPath @@ -102,7 +109,12 @@ internal data class AnkiquestWebSession( .build() .toString() .removeSuffix("/") - val session = if (token.isEmpty()) "null" else JSONObject().put("user", user).put("token", token).toString() + val session = + when { + token.isEmpty() -> "null" + cookie -> JSONObject().put("user", user).toString() + else -> JSONObject().put("user", user).put("token", token).toString() + } return """ (() => { const page = new URL(window.location.href); diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestApiContractTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestApiContractTest.kt new file mode 100644 index 000000000000..f500fe651309 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestApiContractTest.kt @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import android.app.Notification +import android.app.NotificationManager +import androidx.core.content.edit +import androidx.core.content.getSystemService +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.AnkiDroidApp +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.time.MockTime +import com.ichi2.anki.common.time.TimeManager +import com.sun.net.httpserver.HttpServer +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.io.File +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Feeds the server's recorded answers in `src/test/resources/ankiquest-api`, which CI replaces with + * the server's current ones, to the code the app reads them with, so a server change that would + * break the app fails here before it is deployed. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [32]) +class AnkiquestApiContractTest : RobolectricTest() { + private lateinit var server: HttpServer + private lateinit var url: String + private val requests = CopyOnWriteArrayList() + private val manager get() = targetContext.getSystemService()!! + + @Before + fun serveExamples() { + mockkObject(AnkiquestWidget, AnkiquestShare) + every { AnkiquestWidget.requestUpdate(any()) } just Runs + every { AnkiquestShare.picture(any()) } returns null + AnkiDroidApp.sharedPreferencesTestingOverride = targetContext.sharedPrefs() + TimeManager.resetWith(MockTime(EXAMPLE_NOW)) + server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + val path = exchange.requestURI.path + requests += "${exchange.requestMethod} $path" + val sent = exchange.requestBody.bufferedReader().use { it.readText() } + val body = route(exchange.requestMethod, path, sent)?.let(::example)?.toByteArray(Charsets.UTF_8) + exchange.responseHeaders.set("Content-Type", "application/json") + if (body == null) { + exchange.sendResponseHeaders(404, -1) + } else { + exchange.sendResponseHeaders(200, body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } + exchange.close() + } + server.start() + url = "http://127.0.0.1:${server.address.port}" + AnkiDroidApp.sharedPrefs().edit { + putString(Ankiquest.PREF_URL, url) + putString(Ankiquest.PREF_USER, "hill") + putString(Ankiquest.PREF_TOKEN, "hill-token") + } + } + + @After + fun stopServer() { + try { + if (::server.isInitialized) server.stop(0) + } finally { + unmockkObject(AnkiquestWidget, AnkiquestShare) + AnkiDroidApp.sharedPreferencesTestingOverride = null + } + } + + private val account: HomeAccount get() = assertNotNull(AnkiquestHomeData.account()) + + @Test + fun `every example is either read by the app or known to be unused`() { + val directory = File(assertNotNull(javaClass.classLoader?.getResource(EXAMPLES)).toURI()) + val examples = assertNotNull(directory.list()).filter { it.endsWith(".json") }.map { it.removeSuffix(".json") }.toSet() + assertEquals(CONSUMED + UNUSED, examples, "a new or removed example needs a check here") + assertTrue((CONSUMED intersect UNUSED).isEmpty()) + } + + @Test + fun `signing up and signing in keep the returned user and token`() = + runBlocking { + val created = AnkiquestAccount.submit(AnkiquestAccount.Credentials(url, "ana", "password", "Ana", create = true), "Pixel 9") + assertEquals(AnkiquestAccount.Signed(url, "ana", "example-token-1"), created) + val signed = AnkiquestAccount.submit(AnkiquestAccount.Credentials(url, "ana", "password", "", create = false), "Pixel 9") + assertEquals(AnkiquestAccount.Signed(url, "ana", "example-token-2"), signed) + assertTrue(requests.containsAll(listOf("POST /api/accounts", "POST /api/accounts/tokens"))) + } + + @Test + fun `the profile gives the goal, the streak and the connection summary`() = + runBlocking { + val profile = Ankiquest.profile() + AnkiquestNotifier.onProfile(targetContext, profile) + assertEquals( + AnkiquestDailyGoal.ServerGoal(AnkiquestDailyGoal.Choice(AnkiquestDailyGoal.Mode.BACKLOG, 14), since = 20754), + AnkiquestDailyGoal.cached(AnkiDroidApp.sharedPrefs(), account.scope), + ) + assertEquals(AnkiquestAki.Mood.STREAK, AnkiquestAki.mood(null, profile)) + assertTrue(assertNotNull(profile.optJSONArray("quests")).objects().all { it.optString("title").isNotEmpty() }) + + AnkiDroidApp.sharedPrefs().edit { putString(Ankiquest.PREF_TOKEN, "") } + assertEquals( + targetContext.getString(R.string.ankiquest_check_ok, "Hill", 2, 101L, 5L) + "\n\n" + + targetContext.getString(R.string.ankiquest_check_no_token), + Ankiquest.runFromSettings(targetContext, uploadAll = false), + ) + } + + @Test + fun `an upload answers the profile after reading which decks are shared`() = + runBlocking { + assertEquals( + targetContext.getString(R.string.ankiquest_check_ok, "Hill", 2, 101L, 5L), + Ankiquest.runFromSettings(targetContext, uploadAll = true), + ) + assertTrue("GET /api/decks/hill" in requests) + assertTrue("POST /api/reviews/hill" in requests) + assertNull(Ankiquest.feedback(JSONObject(example("upload")).optJSONObject("feedback"), showFeedback = true)) + } + + @Test + fun `deck settings list the friends to share with and the switches`() = + runBlocking { + val settings = Ankiquest.deckNotificationSettings() + val people = settings.getJSONArray("recipients").objects() + assertEquals(listOf("cerro", "friend"), people.map { it.getString("user") }) + assertTrue(people.all { it.getString("display").isNotEmpty() }) + settings.getJSONArray("decks").objects().forEach { deck -> + deck.getString("id") + deck.getBoolean("enabled") + deck.getJSONArray("recipients") + } + val switches = Ankiquest.notificationSwitches() + assertTrue(Ankiquest.celebrations(switches)) + assertFalse(switches.getBoolean(Ankiquest.NUDGES)) + } + + @Test + fun `telling friends about a finished deck reads how many were told`() = + runBlocking { + assertEquals(1, AnkiquestSharing.shareFinishedDeck(account, 1L, "Spanish", listOf("cerro"))) + assertTrue(requests.contains("POST /api/decks/hill/share")) + } + + @Test + fun `the leaderboard orders the widget rows`() = + runBlocking { + val board = Ankiquest.leaderboard() + val week = AnkiquestWidget.forPeriod(board, "week") + assertEquals(listOf("hill", "cerro"), week.objects().map { it.getString("user") }) + val hour = AnkiquestWidget.forPeriod(board, "hour").objects() + assertEquals(listOf(51L, 30L), hour.map { it.getLong("xp") }) + assertEquals(2, AnkiquestWidget.collection(targetContext, week).itemCount) + } + + @Test + fun `a rank change is announced`() = + runBlocking { + AnkiquestNotifier.onLeaderboard(targetContext) + val announced = shadowOf(manager).allNotifications.single() + assertEquals("👑 You took the crown", announced.title()) + assertTrue("You passed Cerro." in announced.text()) + } + + @Test + fun `the inbox shows a celebration, a friend's win with its card and a cheer`() = + runBlocking { + val inbox = assertNotNull(Ankiquest.completionNotifications()) + AnkiquestNotifier.onDeckCompletions(targetContext, inbox.account, inbox.notifications, inbox.scope) + val celebration = shadowOf(manager).getNotification(5_140_001) + assertEquals("Daily goal reached!", celebration.title()) + assertEquals(listOf("Share"), celebration.actions.map { it.title.toString() }) + val share = shadowOf(manager).getNotification(5_140_006) + assertEquals("🎉 Cerro shared a win", share.title()) + assertEquals(listOf("Reply"), share.actions.map { it.title.toString() }, "the example was already cheered") + verify { AnkiquestShare.picture("https://anki.example.test/w/example-link-2.png") } + val reaction = shadowOf(manager).getNotification(5_140_008) + assertEquals("🎉 Cerro cheered", reaction.title()) + assertEquals(8L, AnkiDroidApp.sharedPrefs().getLong("ankiquestCompletionCursor:${inbox.account}", 0)) + } + + @Test + fun `sharing a win, cheering one and the stored settings are read`() = + runBlocking { + assertEquals(2, Ankiquest.shareWinWithFriends(1, account.scope)) + val link = Ankiquest.shareWinLink(1, account.scope) + assertTrue(link.url.contains("/w/"), link.url) + assertTrue(link.image.endsWith(".png"), link.image) + assertTrue(link.text.contains(link.url), link.text) + assertFalse(Ankiquest.react(6, account.notificationAccount, account.scope)) + val sharing = AnkiquestSharing.settings(account) + assertEquals(3, sharing.dailyCap) + assertEquals("ask", sharing.policies["daily_goal"]) + assertTrue(sharing.policies.values.all { it in setOf("private", "ask", "auto") }) + assertEquals( + AnkiquestDailyGoal.ServerGoal(AnkiquestDailyGoal.Choice(AnkiquestDailyGoal.Mode.BACKLOG, 14), since = 20754, chosen = true), + AnkiquestDailyGoal.ServerGoal.parse(AnkiquestSharing.goal(account)), + ) + assertEquals("aki", AnkiquestHomeData.companion(account)) + assertFalse(Ankiquest.streakProtectionEnabled()) + } + + private fun Notification.title() = extras.getCharSequence(Notification.EXTRA_TITLE).toString() + + private fun Notification.text() = extras.getCharSequence(Notification.EXTRA_TEXT).toString() + + private fun route( + method: String, + path: String, + sent: String, + ): String? { + val parts = path.removePrefix("/api/").split('/') + return when { + parts.firstOrNull() == "share" && sent.isNotBlank() && JSONObject(sent).optString("to") == "link" -> "share_to_link" + path == "/api/accounts" && method == "POST" -> "sign_up" + path == "/api/accounts/tokens" && method == "POST" -> "sign_in" + path == "/api/leaderboard" -> "leaderboard" + parts.size == 3 && parts[0] == "decks" && parts[2] == "share" && method == "POST" -> "deck_share" + parts.size == 2 && path.startsWith("/api/") -> ROUTES[parts[0]] + else -> null + } + } + + private fun example(name: String): String = + assertNotNull(javaClass.classLoader?.getResource("$EXAMPLES/$name.json"), "$name.json").readText(Charsets.UTF_8) + + private companion object { + const val EXAMPLES = "ankiquest-api" + const val EXAMPLE_NOW = 1_793_183_460_000L + + val ROUTES = + mapOf( + "profile" to "profile", + "reviews" to "upload", + "rank" to "rank", + "notifications" to "notifications", + "share" to "share_to_friends", + "react" to "react", + "goal" to "goal", + "sharing" to "sharing", + "decks" to "decks", + "companion" to "companion", + "streak-freezes" to "streak_freezes", + ) + + val CONSUMED = ROUTES.values.toSet() + setOf("sign_up", "sign_in", "leaderboard", "share_to_link", "deck_share") + + val UNUSED = setOf("capabilities", "devices", "mute", "wins") + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestCapabilitiesTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestCapabilitiesTest.kt new file mode 100644 index 000000000000..7ecfbd6257d8 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestCapabilitiesTest.kt @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package com.ichi2.anki.ankiquest + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.AnkiDroidApp +import com.ichi2.anki.EmptyApplicationCategory +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.time.MockTime +import com.ichi2.anki.common.time.TimeManager +import com.ichi2.testutils.EmptyApplication +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class) +@Category(EmptyApplicationCategory::class) +class AnkiquestCapabilitiesTest : RobolectricTest() { + private lateinit var server: HttpServer + private lateinit var account: HomeAccount + private val clock = MockTime(1_750_000_000_000) + private val requests = CopyOnWriteArrayList>() + + @Volatile private var status = 200 + + @Volatile private var body = """{"version":1,"features":["activity","cookie_session","wins"]}""" + + @Before + fun prepare() { + AnkiDroidApp.sharedPreferencesTestingOverride = targetContext.sharedPrefs() + TimeManager.resetWith(clock) + AnkiquestCapabilities.forgetMemory() + server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + requests += exchange.requestURI.path to exchange.requestHeaders.getFirst("Authorization") + val bytes = body.toByteArray() + exchange.sendResponseHeaders(status, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + exchange.close() + } + server.start() + account = HomeAccount("http://127.0.0.1:${server.address.port}", "cerro", "secret") + } + + @After + fun stop() { + runCatching { server.stop(0) } + AnkiquestCapabilities.forgetMemory() + AnkiDroidApp.sharedPreferencesTestingOverride = null + } + + @Test + fun `features are read from the answer and anything unreadable counts as none`() { + assertEquals(setOf("activity", "wins"), AnkiquestCapabilities.parse("""{"version":1,"features":["activity",7,"","wins"]}""")) + assertEquals(emptySet(), AnkiquestCapabilities.parse("""{"version":1}""")) + assertEquals(emptySet(), AnkiquestCapabilities.parse("""{"features":"wins"}""")) + assertEquals(emptySet(), AnkiquestCapabilities.parse("not found")) + } + + @Test + fun `capabilities are asked for without credentials`() = + runBlocking { + assertEquals(setOf("activity", "cookie_session", "wins"), AnkiquestCapabilities.refresh(account)) + assertEquals(listOf("/api/capabilities" to null), requests.toList()) + assertTrue(AnkiquestCapabilities.has(account, AnkiquestCapabilities.COOKIE_SESSION)) + assertFalse(AnkiquestCapabilities.has(account, "devices")) + } + + @Test + fun `a server without the endpoint has no features`() = + runBlocking { + AnkiquestCapabilities.refresh(account) + status = 404 + body = """{"error":"not found"}""" + assertEquals(emptySet(), AnkiquestCapabilities.refresh(account)) + assertFalse(AnkiquestCapabilities.has(account, AnkiquestCapabilities.COOKIE_SESSION)) + } + + @Test + fun `an unreadable answer replaces the known features with none`() = + runBlocking { + AnkiquestCapabilities.refresh(account) + body = "not json" + assertEquals(emptySet(), AnkiquestCapabilities.refresh(account)) + } + + @Test + fun `an unreachable server keeps the features it last reported`() = + runBlocking { + AnkiquestCapabilities.refresh(account) + status = 503 + assertEquals(setOf("activity", "cookie_session", "wins"), AnkiquestCapabilities.refresh(account)) + server.stop(0) + assertEquals(setOf("activity", "cookie_session", "wins"), AnkiquestCapabilities.refresh(account)) + } + + @Test + fun `fresh features are reused and asked again once they are old`() = + runBlocking { + AnkiquestCapabilities.current(account) + clock.addM(60) + body = """{"version":1,"features":["wins"]}""" + assertEquals(setOf("activity", "cookie_session", "wins"), AnkiquestCapabilities.current(account)) + assertEquals(1, requests.size) + clock.addM(360) + assertEquals(setOf("wins"), AnkiquestCapabilities.current(account)) + assertEquals(2, requests.size) + } + + @Test + fun `features survive a restart through the stored copy`() = + runBlocking { + AnkiquestCapabilities.current(account) + AnkiquestCapabilities.forgetMemory() + assertTrue(AnkiquestCapabilities.has(account, AnkiquestCapabilities.COOKIE_SESSION)) + assertEquals(setOf("activity", "cookie_session", "wins"), AnkiquestCapabilities.current(account)) + assertEquals(1, requests.size) + } + + @Test + fun `another account or server asks again`() = + runBlocking { + AnkiquestCapabilities.current(account) + body = """{"version":1,"features":["wins"]}""" + assertEquals(setOf("wins"), AnkiquestCapabilities.current(account.copy(user = "hill", token = "other"))) + assertEquals(2, requests.size) + val elsewhere = account.copy(server = "http://127.0.0.1:${server.address.port}/other") + assertFalse(AnkiquestCapabilities.has(elsewhere, "wins")) + AnkiquestCapabilities.current(elsewhere) + assertEquals("/other/api/capabilities", requests.last().first) + assertTrue(AnkiquestCapabilities.has(elsewhere, "wins")) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPollTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPollTest.kt index 68a8d54f6f6c..564f4f2ae73e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPollTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPollTest.kt @@ -32,6 +32,7 @@ import java.net.InetSocketAddress import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -152,6 +153,19 @@ class AnkiquestPollTest : RobolectricTest() { assertTrue(upload < requests.indexOf("/api/leaderboard")) } + @Test + fun `polling keeps the server capabilities current after the inbox`() = + runBlocking { + assertEquals(Result.success(), worker().doWork()) + + val capabilities = requests.indexOf("/api/capabilities") + assertTrue(capabilities > requests.indexOfFirst { it.startsWith("/api/notifications/") }) + assertFalse(AnkiquestCapabilities.has(assertNotNull(AnkiquestHomeData.account()), AnkiquestCapabilities.COOKIE_SESSION)) + requests.clear() + assertEquals(Result.success(), worker().doWork()) + assertTrue(requests.none { it == "/api/capabilities" }, "fresh capabilities are not asked again") + } + @Test fun `a failed upload does not stop polling`() = runBlocking { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestSettingsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestSettingsTest.kt index e5ae2fcb06dd..38c71646ce7b 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestSettingsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestSettingsTest.kt @@ -46,6 +46,8 @@ class AnkiquestSettingsTest : RobolectricTest() { // These UI tests do not load Anki's native collection backend in the background. every { Ankiquest.onActivityResumed(any()) } returns Unit coEvery { Ankiquest.dashboardSession(any()) } returns null + mockkObject(AnkiquestCapabilities) + coEvery { AnkiquestCapabilities.current(any()) } returns emptySet() AnkiDroidApp.sharedPrefs().edit { putString(Ankiquest.PREF_URL, "https://quest.example/anki") putString(Ankiquest.PREF_USER, "cerro") @@ -55,7 +57,7 @@ class AnkiquestSettingsTest : RobolectricTest() { @After fun clearMocks() { - unmockkObject(Ankiquest) + unmockkObject(Ankiquest, AnkiquestCapabilities) } @Test @@ -144,6 +146,38 @@ class AnkiquestSettingsTest : RobolectricTest() { assertTrue(controller.get().isFinishing) } + @Test + fun `dashboard with an installed session cookie learns the member but never the token`() { + coEvery { Ankiquest.dashboardSession(any()) } returns "ankiquest_session=opaque; Path=/; HttpOnly; Secure; SameSite=Lax" + coEvery { AnkiquestCapabilities.current(any()) } returns setOf(AnkiquestCapabilities.COOKIE_SESSION, "wins") + val script = dashboardScript() + assertTrue(script.contains("window.ankiquestSession = {\"user\":\"cerro\"};")) + assertFalse(script.contains("saved-test-token")) + } + + @Test + fun `dashboard keeps the token while the server does not report cookie sessions`() { + coEvery { Ankiquest.dashboardSession(any()) } returns "ankiquest_session=opaque; Path=/; HttpOnly; Secure; SameSite=Lax" + coEvery { AnkiquestCapabilities.current(any()) } returns setOf("wins") + val script = dashboardScript() + assertTrue(script.contains("saved-test-token")) + coVerify { AnkiquestCapabilities.current(match { it.server == "https://quest.example/anki" && it.user == "cerro" }) } + } + + @Test + fun `dashboard keeps the token when no session cookie was installed`() { + coEvery { AnkiquestCapabilities.current(any()) } returns setOf(AnkiquestCapabilities.COOKIE_SESSION) + assertTrue(dashboardScript().contains("saved-test-token")) + } + + private fun dashboardScript(): String { + val controller = Robolectric.buildActivity(AnkiquestActivity::class.java).create().also { shadowOf(Looper.getMainLooper()).idle() } + saveControllerForCleanup(controller) + val webView = controller.get().findViewById(R.id.web_view) + shadowOf(webView).webViewClient.onPageFinished(webView, "https://quest.example/anki/?embed=1#cerro") + return assertNotNull(shadowOf(webView).lastEvaluatedJavascript) + } + @Test fun `dashboard receives the saved account without asking for its token again`() { val controller = Robolectric.buildActivity(AnkiquestActivity::class.java).create().also { shadowOf(Looper.getMainLooper()).idle() } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestWebSessionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestWebSessionTest.kt index 73bec5bda1d8..80fa01e78850 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestWebSessionTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestWebSessionTest.kt @@ -87,6 +87,17 @@ class AnkiquestWebSessionTest { assertTrue(script.contains("new CustomEvent('ankiquest-auth')")) } + @Test + fun `with the session cookie installed the page learns the member but not the token`() { + val script = assertNotNull(session.script("https://quest.example/anki/", cookie = true)) + + val handed = JSONObject(script.substringAfter("window.ankiquestSession = ").substringBefore(";\n")) + assertEquals("cerro", handed.getString("user")) + assertFalse(handed.has("token")) + assertFalse(script.contains("secret")) + assertNull(session.script("https://other.example/anki/", cookie = true)) + } + @Test fun `without a token the page is told it is signed out`() { val signedOut = AnkiquestWebSession("https://quest.example/anki/#cerro", "cerro", "") diff --git a/AnkiDroid/src/test/resources/ankiquest-api/capabilities.json b/AnkiDroid/src/test/resources/ankiquest-api/capabilities.json new file mode 100644 index 000000000000..ad01d684a69a --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/capabilities.json @@ -0,0 +1,12 @@ +{ + "features": [ + "activity", + "cookie_session", + "devices", + "goal_backlog_days", + "reactions", + "share_cards", + "wins" + ], + "version": 1 +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/companion.json b/AnkiDroid/src/test/resources/ankiquest-api/companion.json new file mode 100644 index 000000000000..27ba293bce42 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/companion.json @@ -0,0 +1,3 @@ +{ + "companion": "aki" +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/deck_share.json b/AnkiDroid/src/test/resources/ankiquest-api/deck_share.json new file mode 100644 index 000000000000..33e6a1feaa37 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/deck_share.json @@ -0,0 +1,3 @@ +{ + "recipients": 1 +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/decks.json b/AnkiDroid/src/test/resources/ankiquest-api/decks.json new file mode 100644 index 000000000000..be5acb9b49ed --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/decks.json @@ -0,0 +1,28 @@ +{ + "celebrations": true, + "decks": [ + { + "enabled": false, + "id": "1", + "name": "Spanish", + "recipients": [] + }, + { + "enabled": false, + "id": "2", + "name": "Spanish::Verbs", + "recipients": [] + } + ], + "nudges": false, + "recipients": [ + { + "display": "Cerro", + "user": "cerro" + }, + { + "display": "Friend", + "user": "friend" + } + ] +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/devices.json b/AnkiDroid/src/test/resources/ankiquest-api/devices.json new file mode 100644 index 000000000000..47156134f0b5 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/devices.json @@ -0,0 +1,18 @@ +{ + "devices": [ + { + "created_at": 1793183400000, + "current": true, + "id": "example-device-1", + "kind": "app", + "name": "Pixel 9" + }, + { + "created_at": 1793183400000, + "current": false, + "id": "example-device-2", + "kind": "app", + "name": "Pixel 8" + } + ] +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/goal.json b/AnkiDroid/src/test/resources/ankiquest-api/goal.json new file mode 100644 index 000000000000..c1d8aef03824 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/goal.json @@ -0,0 +1,6 @@ +{ + "chosen": true, + "mode": "backlog", + "since": 20754, + "value": 14 +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/leaderboard.json b/AnkiDroid/src/test/resources/ankiquest-api/leaderboard.json new file mode 100644 index 000000000000..daa10dd79929 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/leaderboard.json @@ -0,0 +1,48 @@ +[ + { + "day_ends_at": 1793246400000, + "display": "Hill", + "level": 2, + "period": "week", + "periods": { + "all": 101, + "day": 101, + "hour": 51, + "month": 101, + "week": 101, + "year": 101 + }, + "streak": 1, + "streak_minutes": 0, + "streak_state": "studied", + "today_minutes": 0, + "today_reviews": 5, + "user": "hill", + "week_xp": 101, + "xp": 101, + "xp_total": 101 + }, + { + "day_ends_at": 1793246400000, + "display": "Cerro", + "level": 1, + "period": "week", + "periods": { + "all": 31, + "day": 31, + "hour": 30, + "month": 31, + "week": 31, + "year": 31 + }, + "streak": 1, + "streak_minutes": 0, + "streak_state": "studied", + "today_minutes": 0, + "today_reviews": 3, + "user": "cerro", + "week_xp": 31, + "xp": 31, + "xp_total": 31 + } +] diff --git a/AnkiDroid/src/test/resources/ankiquest-api/mute.json b/AnkiDroid/src/test/resources/ankiquest-api/mute.json new file mode 100644 index 000000000000..e9306f82d4b9 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/mute.json @@ -0,0 +1,13 @@ +{ + "friends": [], + "groups": [], + "incoming": [], + "muted": [ + { + "display": "Cerro", + "user": "cerro" + } + ], + "outgoing": [], + "public": true +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/notifications.json b/AnkiDroid/src/test/resources/ankiquest-api/notifications.json new file mode 100644 index 000000000000..4662c5ea99ae --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/notifications.json @@ -0,0 +1,51 @@ +[ + { + "action_required": false, + "body": "5 cards today in 0 minutes.", + "challenge_id": null, + "created_at": 1793183400, + "day": 20754, + "id": 1, + "kind": "celebration", + "read_at": null, + "replied": false, + "route": null, + "sender": "", + "title": "Daily goal reached!", + "topic": "goal", + "win": 1 + }, + { + "action_required": false, + "body": "7 day streak!: “A week in a row.”", + "challenge_id": null, + "created_at": 1793183400, + "day": 20754, + "id": 6, + "image": "https://anki.example.test/w/example-link-2.png", + "kind": "share", + "reacted": true, + "read_at": null, + "replied": false, + "route": null, + "sender": "cerro", + "title": "🎉 Cerro shared a win", + "topic": "streak", + "win": 2 + }, + { + "action_required": false, + "body": "“Daily goal reached!”", + "challenge_id": null, + "created_at": 1793183400, + "day": 20754, + "id": 8, + "kind": "reaction", + "read_at": null, + "replied": false, + "route": null, + "sender": "cerro", + "title": "🎉 Cerro cheered", + "win": 1 + } +] diff --git a/AnkiDroid/src/test/resources/ankiquest-api/profile.json b/AnkiDroid/src/test/resources/ankiquest-api/profile.json new file mode 100644 index 000000000000..8896aacda83b --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/profile.json @@ -0,0 +1,2271 @@ +{ + "achievements": [ + { + "description": "Review 100 cards in total", + "id": "reviews-100", + "progress": 5, + "reward": 100, + "target": 100, + "title": "First Hundred", + "unlocked": null + }, + { + "description": "Review 1000 cards in total", + "id": "reviews-1000", + "progress": 5, + "reward": 200, + "target": 1000, + "title": "Thousand Flips", + "unlocked": null + }, + { + "description": "Review 5000 cards in total", + "id": "reviews-5000", + "progress": 5, + "reward": 300, + "target": 5000, + "title": "Card Shark", + "unlocked": null + }, + { + "description": "Review 10000 cards in total", + "id": "reviews-10000", + "progress": 5, + "reward": 400, + "target": 10000, + "title": "Ten Thousand Hours, Sort Of", + "unlocked": null + }, + { + "description": "Review 25000 cards in total", + "id": "reviews-25000", + "progress": 5, + "reward": 500, + "target": 25000, + "title": "Memory Palace", + "unlocked": null + }, + { + "description": "Review 50000 cards in total", + "id": "reviews-50000", + "progress": 5, + "reward": 600, + "target": 50000, + "title": "Living Library", + "unlocked": null + }, + { + "description": "Review 100000 cards in total", + "id": "reviews-100000", + "progress": 5, + "reward": 700, + "target": 100000, + "title": "Mnemosyne", + "unlocked": null + }, + { + "description": "Review 250000 cards in total", + "id": "reviews-250000", + "progress": 5, + "reward": 800, + "target": 250000, + "title": "Quarter Million", + "unlocked": null + }, + { + "description": "Review 500000 cards in total", + "id": "reviews-500000", + "progress": 5, + "reward": 900, + "target": 500000, + "title": "Half a Million", + "unlocked": null + }, + { + "description": "Review 1000000 cards in total", + "id": "reviews-1000000", + "progress": 5, + "reward": 1000, + "target": 1000000, + "title": "The Millionaire", + "unlocked": null + }, + { + "description": "Reach a 3 day streak", + "id": "streak-3", + "progress": 1, + "reward": 100, + "target": 3, + "title": "Warming Up", + "unlocked": null + }, + { + "description": "Reach a 7 day streak", + "id": "streak-7", + "progress": 1, + "reward": 200, + "target": 7, + "title": "Full Week", + "unlocked": null + }, + { + "description": "Reach a 14 day streak", + "id": "streak-14", + "progress": 1, + "reward": 300, + "target": 14, + "title": "Fortnight", + "unlocked": null + }, + { + "description": "Reach a 30 day streak", + "id": "streak-30", + "progress": 1, + "reward": 400, + "target": 30, + "title": "Month of Mondays", + "unlocked": null + }, + { + "description": "Reach a 60 day streak", + "id": "streak-60", + "progress": 1, + "reward": 500, + "target": 60, + "title": "Habit Formed", + "unlocked": null + }, + { + "description": "Reach a 100 day streak", + "id": "streak-100", + "progress": 1, + "reward": 600, + "target": 100, + "title": "Centurion", + "unlocked": null + }, + { + "description": "Reach a 200 day streak", + "id": "streak-200", + "progress": 1, + "reward": 700, + "target": 200, + "title": "Unbreakable", + "unlocked": null + }, + { + "description": "Reach a 365 day streak", + "id": "streak-365", + "progress": 1, + "reward": 800, + "target": 365, + "title": "Orbit Complete", + "unlocked": null + }, + { + "description": "Reach a 500 day streak", + "id": "streak-500", + "progress": 1, + "reward": 900, + "target": 500, + "title": "Five Hundred Sunrises", + "unlocked": null + }, + { + "description": "Reach a 730 day streak", + "id": "streak-730", + "progress": 1, + "reward": 1000, + "target": 730, + "title": "Two Orbits", + "unlocked": null + }, + { + "description": "Reach a 1000 day streak", + "id": "streak-1000", + "progress": 1, + "reward": 1100, + "target": 1000, + "title": "Thousand Days", + "unlocked": null + }, + { + "description": "Reach a 1500 day streak", + "id": "streak-1500", + "progress": 1, + "reward": 1200, + "target": 1500, + "title": "Unstoppable", + "unlocked": null + }, + { + "description": "Reach a 2000 day streak", + "id": "streak-2000", + "progress": 1, + "reward": 1300, + "target": 2000, + "title": "Perennial", + "unlocked": null + }, + { + "description": "Review 100 cards in one day", + "id": "dayreviews-100", + "progress": 5, + "reward": 100, + "target": 100, + "title": "Busy Day", + "unlocked": null + }, + { + "description": "Review 250 cards in one day", + "id": "dayreviews-250", + "progress": 5, + "reward": 200, + "target": 250, + "title": "Marathon", + "unlocked": null + }, + { + "description": "Review 500 cards in one day", + "id": "dayreviews-500", + "progress": 5, + "reward": 300, + "target": 500, + "title": "Ultramarathon", + "unlocked": null + }, + { + "description": "Review 750 cards in one day", + "id": "dayreviews-750", + "progress": 5, + "reward": 400, + "target": 750, + "title": "Iron Lung", + "unlocked": null + }, + { + "description": "Review 1000 cards in one day", + "id": "dayreviews-1000", + "progress": 5, + "reward": 500, + "target": 1000, + "title": "Thousand in a Day", + "unlocked": null + }, + { + "description": "Review 1500 cards in one day", + "id": "dayreviews-1500", + "progress": 5, + "reward": 600, + "target": 1500, + "title": "Grinder", + "unlocked": null + }, + { + "description": "Review 2000 cards in one day", + "id": "dayreviews-2000", + "progress": 5, + "reward": 700, + "target": 2000, + "title": "Madness", + "unlocked": null + }, + { + "description": "Reach a 50 card combo", + "id": "combo-50", + "progress": 5, + "reward": 100, + "target": 50, + "title": "In the Zone", + "unlocked": null + }, + { + "description": "Reach a 100 card combo", + "id": "combo-100", + "progress": 5, + "reward": 200, + "target": 100, + "title": "Flow State", + "unlocked": null + }, + { + "description": "Reach a 250 card combo", + "id": "combo-250", + "progress": 5, + "reward": 300, + "target": 250, + "title": "Trance", + "unlocked": null + }, + { + "description": "Reach a 500 card combo", + "id": "combo-500", + "progress": 5, + "reward": 400, + "target": 500, + "title": "Zen", + "unlocked": null + }, + { + "description": "Reach a 1000 card combo", + "id": "combo-1000", + "progress": 5, + "reward": 500, + "target": 1000, + "title": "Nirvana", + "unlocked": null + }, + { + "description": "Study for 10 hours in total", + "id": "hours-10", + "progress": 0, + "reward": 100, + "target": 10, + "title": "Ten Hours In", + "unlocked": null + }, + { + "description": "Study for 50 hours in total", + "id": "hours-50", + "progress": 0, + "reward": 200, + "target": 50, + "title": "Fifty Hours In", + "unlocked": null + }, + { + "description": "Study for 100 hours in total", + "id": "hours-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Hundred Hours In", + "unlocked": null + }, + { + "description": "Study for 500 hours in total", + "id": "hours-500", + "progress": 0, + "reward": 400, + "target": 500, + "title": "Scholar", + "unlocked": null + }, + { + "description": "Study for 1000 hours in total", + "id": "hours-1000", + "progress": 0, + "reward": 500, + "target": 1000, + "title": "Thousand Hours", + "unlocked": null + }, + { + "description": "Study for 2000 hours in total", + "id": "hours-2000", + "progress": 0, + "reward": 600, + "target": 2000, + "title": "Two Thousand Hours", + "unlocked": null + }, + { + "description": "Study for 5000 hours in total", + "id": "hours-5000", + "progress": 0, + "reward": 700, + "target": 5000, + "title": "Lifer", + "unlocked": null + }, + { + "description": "Review 100 mature cards", + "id": "mature-100", + "progress": 0, + "reward": 100, + "target": 100, + "title": "Long Term", + "unlocked": null + }, + { + "description": "Review 1000 mature cards", + "id": "mature-1000", + "progress": 0, + "reward": 200, + "target": 1000, + "title": "Deep Roots", + "unlocked": null + }, + { + "description": "Review 10000 mature cards", + "id": "mature-10000", + "progress": 0, + "reward": 300, + "target": 10000, + "title": "Old Growth", + "unlocked": null + }, + { + "description": "Review 25000 mature cards", + "id": "mature-25000", + "progress": 0, + "reward": 400, + "target": 25000, + "title": "Ancient Forest", + "unlocked": null + }, + { + "description": "Review 50000 mature cards", + "id": "mature-50000", + "progress": 0, + "reward": 500, + "target": 50000, + "title": "Redwood", + "unlocked": null + }, + { + "description": "Review 100000 mature cards", + "id": "mature-100000", + "progress": 0, + "reward": 600, + "target": 100000, + "title": "Bedrock", + "unlocked": null + }, + { + "description": "Learn 100 new cards", + "id": "newcards-100", + "progress": 0, + "reward": 100, + "target": 100, + "title": "Collector", + "unlocked": null + }, + { + "description": "Learn 1000 new cards", + "id": "newcards-1000", + "progress": 0, + "reward": 200, + "target": 1000, + "title": "Curator", + "unlocked": null + }, + { + "description": "Learn 5000 new cards", + "id": "newcards-5000", + "progress": 0, + "reward": 300, + "target": 5000, + "title": "Archivist", + "unlocked": null + }, + { + "description": "Learn 10000 new cards", + "id": "newcards-10000", + "progress": 0, + "reward": 400, + "target": 10000, + "title": "Encyclopedist", + "unlocked": null + }, + { + "description": "Learn 25000 new cards", + "id": "newcards-25000", + "progress": 0, + "reward": 500, + "target": 25000, + "title": "Lexicographer", + "unlocked": null + }, + { + "description": "Learn 50000 new cards", + "id": "newcards-50000", + "progress": 0, + "reward": 600, + "target": 50000, + "title": "Polymath", + "unlocked": null + }, + { + "description": "Complete 10 quests", + "id": "quests-10", + "progress": 1, + "reward": 100, + "target": 10, + "title": "Adventurer", + "unlocked": null + }, + { + "description": "Complete 50 quests", + "id": "quests-50", + "progress": 1, + "reward": 200, + "target": 50, + "title": "Quest Hound", + "unlocked": null + }, + { + "description": "Complete 200 quests", + "id": "quests-200", + "progress": 1, + "reward": 300, + "target": 200, + "title": "Completionist", + "unlocked": null + }, + { + "description": "Complete 500 quests", + "id": "quests-500", + "progress": 1, + "reward": 400, + "target": 500, + "title": "Quest Master", + "unlocked": null + }, + { + "description": "Complete 1000 quests", + "id": "quests-1000", + "progress": 1, + "reward": 500, + "target": 1000, + "title": "Legend", + "unlocked": null + }, + { + "description": "Complete 2500 quests", + "id": "quests-2500", + "progress": 1, + "reward": 600, + "target": 2500, + "title": "Mythic", + "unlocked": null + }, + { + "description": "Complete every quest on 7 days", + "id": "perfectdays-7", + "progress": 0, + "reward": 100, + "target": 7, + "title": "Perfect Week", + "unlocked": null + }, + { + "description": "Complete every quest on 30 days", + "id": "perfectdays-30", + "progress": 0, + "reward": 200, + "target": 30, + "title": "Perfect Month", + "unlocked": null + }, + { + "description": "Complete every quest on 100 days", + "id": "perfectdays-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Perfectionist", + "unlocked": null + }, + { + "description": "Complete every quest on 365 days", + "id": "perfectdays-365", + "progress": 0, + "reward": 400, + "target": 365, + "title": "Flawless Year", + "unlocked": null + }, + { + "description": "Study before 7am on 5 days", + "id": "earlydays-5", + "progress": 0, + "reward": 100, + "target": 5, + "title": "Early Bird", + "unlocked": null + }, + { + "description": "Study before 7am on 30 days", + "id": "earlydays-30", + "progress": 0, + "reward": 200, + "target": 30, + "title": "Dawn Patrol", + "unlocked": null + }, + { + "description": "Study before 7am on 100 days", + "id": "earlydays-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Sunrise Scholar", + "unlocked": null + }, + { + "description": "Study after 11pm on 5 days", + "id": "latedays-5", + "progress": 0, + "reward": 100, + "target": 5, + "title": "Night Owl", + "unlocked": null + }, + { + "description": "Study after 11pm on 30 days", + "id": "latedays-30", + "progress": 0, + "reward": 200, + "target": 30, + "title": "Nocturnal", + "unlocked": null + }, + { + "description": "Study after 11pm on 100 days", + "id": "latedays-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Creature of the Night", + "unlocked": null + }, + { + "description": "Study on 30 different days", + "id": "daysactive-30", + "progress": 1, + "reward": 100, + "target": 30, + "title": "Regular", + "unlocked": null + }, + { + "description": "Study on 100 different days", + "id": "daysactive-100", + "progress": 1, + "reward": 200, + "target": 100, + "title": "Hundred Days", + "unlocked": null + }, + { + "description": "Study on 365 different days", + "id": "daysactive-365", + "progress": 1, + "reward": 300, + "target": 365, + "title": "A Year of Days", + "unlocked": null + }, + { + "description": "Study on 730 different days", + "id": "daysactive-730", + "progress": 1, + "reward": 400, + "target": 730, + "title": "Two Years of Days", + "unlocked": null + }, + { + "description": "Study on 1000 different days", + "id": "daysactive-1000", + "progress": 1, + "reward": 500, + "target": 1000, + "title": "Thousand Days Studied", + "unlocked": null + }, + { + "description": "Study on 2000 different days", + "id": "daysactive-2000", + "progress": 1, + "reward": 600, + "target": 2000, + "title": "Two Thousand Days Studied", + "unlocked": null + }, + { + "description": "Relearn 100 forgotten cards", + "id": "relearns-100", + "progress": 0, + "reward": 100, + "target": 100, + "title": "Second Chance", + "unlocked": null + }, + { + "description": "Relearn 1000 forgotten cards", + "id": "relearns-1000", + "progress": 0, + "reward": 200, + "target": 1000, + "title": "Persistence", + "unlocked": null + }, + { + "description": "Relearn 10000 forgotten cards", + "id": "relearns-10000", + "progress": 0, + "reward": 300, + "target": 10000, + "title": "Never Give Up", + "unlocked": null + }, + { + "description": "Relearn 50000 forgotten cards", + "id": "relearns-50000", + "progress": 0, + "reward": 400, + "target": 50000, + "title": "Sisyphus", + "unlocked": null + }, + { + "description": "Recall a card last seen 180 days before", + "id": "maxinterval-180", + "progress": 1, + "reward": 100, + "target": 180, + "title": "Half-Year Memory", + "unlocked": null + }, + { + "description": "Recall a card last seen 365 days before", + "id": "maxinterval-365", + "progress": 1, + "reward": 200, + "target": 365, + "title": "Year-Old Memory", + "unlocked": null + }, + { + "description": "Recall a card last seen 1825 days before", + "id": "maxinterval-1825", + "progress": 1, + "reward": 300, + "target": 1825, + "title": "Five-Year Memory", + "unlocked": null + }, + { + "description": "Recall a card last seen 3650 days before", + "id": "maxinterval-3650", + "progress": 1, + "reward": 400, + "target": 3650, + "title": "Decade Memory", + "unlocked": null + }, + { + "description": "Review 1000 different cards", + "id": "distinctcards-1000", + "progress": 5, + "reward": 100, + "target": 1000, + "title": "Wide Net", + "unlocked": null + }, + { + "description": "Review 5000 different cards", + "id": "distinctcards-5000", + "progress": 5, + "reward": 200, + "target": 5000, + "title": "Big Deck", + "unlocked": null + }, + { + "description": "Review 20000 different cards", + "id": "distinctcards-20000", + "progress": 5, + "reward": 300, + "target": 20000, + "title": "Vast Library", + "unlocked": null + }, + { + "description": "Review 50000 different cards", + "id": "distinctcards-50000", + "progress": 5, + "reward": 400, + "target": 50000, + "title": "Everything Everywhere", + "unlocked": null + }, + { + "description": "Study 60 minutes in one sitting", + "id": "longestsession-60", + "progress": 0, + "reward": 100, + "target": 60, + "title": "Deep Work", + "unlocked": null + }, + { + "description": "Study 120 minutes in one sitting", + "id": "longestsession-120", + "progress": 0, + "reward": 200, + "target": 120, + "title": "Two-Hour Sitting", + "unlocked": null + }, + { + "description": "Study 240 minutes in one sitting", + "id": "longestsession-240", + "progress": 0, + "reward": 300, + "target": 240, + "title": "Iron Chair", + "unlocked": null + }, + { + "description": "Study in 5 separate sessions in one day", + "id": "maxsessions-5", + "progress": 1, + "reward": 100, + "target": 5, + "title": "Snacker", + "unlocked": null + }, + { + "description": "Study in 10 separate sessions in one day", + "id": "maxsessions-10", + "progress": 1, + "reward": 200, + "target": 10, + "title": "Grazer", + "unlocked": null + }, + { + "description": "Study in 20 separate sessions in one day", + "id": "maxsessions-20", + "progress": 1, + "reward": 300, + "target": 20, + "title": "Can't Stop", + "unlocked": null + }, + { + "description": "Review 1000 cards in one calendar month", + "id": "bestmonth-1000", + "progress": 5, + "reward": 100, + "target": 1000, + "title": "Busy Month", + "unlocked": null + }, + { + "description": "Review 3000 cards in one calendar month", + "id": "bestmonth-3000", + "progress": 5, + "reward": 200, + "target": 3000, + "title": "Big Month", + "unlocked": null + }, + { + "description": "Review 10000 cards in one calendar month", + "id": "bestmonth-10000", + "progress": 5, + "reward": 300, + "target": 10000, + "title": "Monster Month", + "unlocked": null + }, + { + "description": "Review 20000 cards in one calendar month", + "id": "bestmonth-20000", + "progress": 5, + "reward": 400, + "target": 20000, + "title": "Month of Madness", + "unlocked": null + }, + { + "description": "Study on 10 Saturdays or Sundays", + "id": "weekenddays-10", + "progress": 0, + "reward": 100, + "target": 10, + "title": "Weekend Warrior", + "unlocked": null + }, + { + "description": "Study on 52 Saturdays or Sundays", + "id": "weekenddays-52", + "progress": 0, + "reward": 200, + "target": 52, + "title": "Weekend Regular", + "unlocked": null + }, + { + "description": "Study on 200 Saturdays or Sundays", + "id": "weekenddays-200", + "progress": 0, + "reward": 300, + "target": 200, + "title": "No Days Off", + "unlocked": null + }, + { + "description": "Come back after a month away", + "id": "comebacks-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Comeback", + "unlocked": null + }, + { + "description": "Come back after a month away 3 times", + "id": "comebacks-3", + "progress": 0, + "reward": 200, + "target": 3, + "title": "Phoenix", + "unlocked": null + }, + { + "description": "Have a streak freeze save you 1 times", + "id": "freezesused-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Saved by the Ice", + "unlocked": null + }, + { + "description": "Have a streak freeze save you 10 times", + "id": "freezesused-10", + "progress": 0, + "reward": 200, + "target": 10, + "title": "Glacier", + "unlocked": null + }, + { + "description": "Have a streak freeze save you 50 times", + "id": "freezesused-50", + "progress": 0, + "reward": 300, + "target": 50, + "title": "Ice Age", + "unlocked": null + }, + { + "description": "Review 100+ cards in a day averaging under 5 seconds, 1 times", + "id": "fastdays-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Speed Demon", + "unlocked": null + }, + { + "description": "Review 100+ cards in a day averaging under 5 seconds, 10 times", + "id": "fastdays-10", + "progress": 0, + "reward": 200, + "target": 10, + "title": "Lightning", + "unlocked": null + }, + { + "description": "Review 100+ cards in a day averaging under 5 seconds, 50 times", + "id": "fastdays-50", + "progress": 0, + "reward": 300, + "target": 50, + "title": "Quicksilver", + "unlocked": null + }, + { + "description": "Study on New Year's Day 1 times", + "id": "newyeardays-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "New Year, Same Me", + "unlocked": null + }, + { + "description": "Study on New Year's Day 3 times", + "id": "newyeardays-3", + "progress": 0, + "reward": 200, + "target": 3, + "title": "Tradition", + "unlocked": null + }, + { + "description": "Study on the anniversary of your first review 1 times", + "id": "anniversaries-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Anniversary", + "unlocked": null + }, + { + "description": "Study on the anniversary of your first review 5 times", + "id": "anniversaries-5", + "progress": 0, + "reward": 200, + "target": 5, + "title": "Old Friends", + "unlocked": null + }, + { + "description": "Study on the anniversary of your first review 10 times", + "id": "anniversaries-10", + "progress": 0, + "reward": 300, + "target": 10, + "title": "Lifelong", + "unlocked": null + }, + { + "description": "Keep going for 1 years since your first review", + "id": "years-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "One Year In", + "unlocked": null + }, + { + "description": "Keep going for 3 years since your first review", + "id": "years-3", + "progress": 0, + "reward": 200, + "target": 3, + "title": "Three Years In", + "unlocked": null + }, + { + "description": "Keep going for 5 years since your first review", + "id": "years-5", + "progress": 0, + "reward": 300, + "target": 5, + "title": "Veteran", + "unlocked": null + }, + { + "description": "Keep going for 10 years since your first review", + "id": "years-10", + "progress": 0, + "reward": 400, + "target": 10, + "title": "Decade of Anki", + "unlocked": null + } + ], + "at_risk": false, + "conquests": [], + "day_ends_at": 1793246400000, + "display": "Hill", + "freeze_earned_today": false, + "freezes": 0, + "freezes_enabled": false, + "goal": { + "day": 20754, + "mode": "backlog", + "progress": null, + "reached": false, + "since": 20754, + "value": 14 + }, + "heatmap": [ + { + "date": "2026-04-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-31", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-31", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-31", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-28", + "frozen": false, + "reviews": 5, + "xp": 101 + } + ], + "last_review_id": 1793182204000, + "level": 2, + "lifetime": { + "best_combo": 5, + "best_day": 5, + "best_streak": 1, + "best_streak_at": 1793160000000, + "conquered": 0, + "days_active": 1, + "first_day_at": 1793160000000, + "hours": 0, + "quests": 1, + "reviews": 5 + }, + "local_hour": 10, + "periods": { + "all": 101, + "day": 101, + "hour": 51, + "month": 101, + "week": 101, + "year": 101 + }, + "quests": [ + { + "done": false, + "progress": 5, + "reward": 50, + "target": 15, + "title": "Review 15 cards" + }, + { + "done": true, + "progress": 5, + "reward": 50, + "target": 5, + "title": "Review 5 cards before noon" + }, + { + "done": false, + "progress": 1, + "reward": 50, + "target": 2, + "title": "Study in 2 separate sessions" + } + ], + "records": { + "day": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + }, + "hour": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 51 + }, + "month": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + }, + "week": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + }, + "year": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + } + }, + "stored_freezes": 0, + "streak": 1, + "streak_state": "studied", + "streak_warning": null, + "today": { + "current_combo": 5, + "max_combo": 5, + "minutes": 0, + "new_cards": 0, + "reviews": 5, + "streak_minutes": 0, + "xp": 101 + }, + "user": "hill", + "week_xp": 101, + "xp_for_next": 280, + "xp_into_level": 1, + "xp_total": 101 +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/rank.json b/AnkiDroid/src/test/resources/ankiquest-api/rank.json new file mode 100644 index 000000000000..fddf4f563ddc --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/rank.json @@ -0,0 +1,7 @@ +{ + "change": { + "body": "You passed Cerro.", + "title": "👑 You took the crown" + }, + "order": ["hill", "cerro"] +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/react.json b/AnkiDroid/src/test/resources/ankiquest-api/react.json new file mode 100644 index 000000000000..741d563c9bcd --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/react.json @@ -0,0 +1,3 @@ +{ + "already": false +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/share_to_friends.json b/AnkiDroid/src/test/resources/ankiquest-api/share_to_friends.json new file mode 100644 index 000000000000..10bc8757f5bd --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/share_to_friends.json @@ -0,0 +1,7 @@ +{ + "link": { + "image": "https://anki.example.test/w/example-link-1.png", + "url": "https://anki.example.test/w/example-link-1" + }, + "recipients": 2 +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/share_to_link.json b/AnkiDroid/src/test/resources/ankiquest-api/share_to_link.json new file mode 100644 index 000000000000..b018991db631 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/share_to_link.json @@ -0,0 +1,5 @@ +{ + "image": "https://anki.example.test/w/example-link-1.png", + "text": "Daily goal reached!: 5 cards today in 0 minutes. Join me on AnkiQuest: https://anki.example.test/w/example-link-1", + "url": "https://anki.example.test/w/example-link-1" +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/sharing.json b/AnkiDroid/src/test/resources/ankiquest-api/sharing.json new file mode 100644 index 000000000000..e44573e71cc1 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/sharing.json @@ -0,0 +1,15 @@ +{ + "daily_cap": 3, + "policies": { + "achievement": "ask", + "challenge": "ask", + "conquered": "ask", + "daily_goal": "ask", + "level": "ask", + "place": "ask", + "quest": "private", + "record": "ask", + "streak": "ask", + "streak_secured": "private" + } +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/sign_in.json b/AnkiDroid/src/test/resources/ankiquest-api/sign_in.json new file mode 100644 index 000000000000..71a1313c2d38 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/sign_in.json @@ -0,0 +1,5 @@ +{ + "display": "Ana", + "token": "example-token-2", + "user": "ana" +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/sign_up.json b/AnkiDroid/src/test/resources/ankiquest-api/sign_up.json new file mode 100644 index 000000000000..efb0612e3155 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/sign_up.json @@ -0,0 +1,5 @@ +{ + "display": "Ana", + "token": "example-token-1", + "user": "ana" +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/streak_freezes.json b/AnkiDroid/src/test/resources/ankiquest-api/streak_freezes.json new file mode 100644 index 000000000000..88a53f02dac3 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/streak_freezes.json @@ -0,0 +1,5 @@ +{ + "capacity": 3, + "enabled": false, + "freezes": 0 +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/upload.json b/AnkiDroid/src/test/resources/ankiquest-api/upload.json new file mode 100644 index 000000000000..abe61f9c746f --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/upload.json @@ -0,0 +1,2276 @@ +{ + "achievements": [ + { + "description": "Review 100 cards in total", + "id": "reviews-100", + "progress": 5, + "reward": 100, + "target": 100, + "title": "First Hundred", + "unlocked": null + }, + { + "description": "Review 1000 cards in total", + "id": "reviews-1000", + "progress": 5, + "reward": 200, + "target": 1000, + "title": "Thousand Flips", + "unlocked": null + }, + { + "description": "Review 5000 cards in total", + "id": "reviews-5000", + "progress": 5, + "reward": 300, + "target": 5000, + "title": "Card Shark", + "unlocked": null + }, + { + "description": "Review 10000 cards in total", + "id": "reviews-10000", + "progress": 5, + "reward": 400, + "target": 10000, + "title": "Ten Thousand Hours, Sort Of", + "unlocked": null + }, + { + "description": "Review 25000 cards in total", + "id": "reviews-25000", + "progress": 5, + "reward": 500, + "target": 25000, + "title": "Memory Palace", + "unlocked": null + }, + { + "description": "Review 50000 cards in total", + "id": "reviews-50000", + "progress": 5, + "reward": 600, + "target": 50000, + "title": "Living Library", + "unlocked": null + }, + { + "description": "Review 100000 cards in total", + "id": "reviews-100000", + "progress": 5, + "reward": 700, + "target": 100000, + "title": "Mnemosyne", + "unlocked": null + }, + { + "description": "Review 250000 cards in total", + "id": "reviews-250000", + "progress": 5, + "reward": 800, + "target": 250000, + "title": "Quarter Million", + "unlocked": null + }, + { + "description": "Review 500000 cards in total", + "id": "reviews-500000", + "progress": 5, + "reward": 900, + "target": 500000, + "title": "Half a Million", + "unlocked": null + }, + { + "description": "Review 1000000 cards in total", + "id": "reviews-1000000", + "progress": 5, + "reward": 1000, + "target": 1000000, + "title": "The Millionaire", + "unlocked": null + }, + { + "description": "Reach a 3 day streak", + "id": "streak-3", + "progress": 1, + "reward": 100, + "target": 3, + "title": "Warming Up", + "unlocked": null + }, + { + "description": "Reach a 7 day streak", + "id": "streak-7", + "progress": 1, + "reward": 200, + "target": 7, + "title": "Full Week", + "unlocked": null + }, + { + "description": "Reach a 14 day streak", + "id": "streak-14", + "progress": 1, + "reward": 300, + "target": 14, + "title": "Fortnight", + "unlocked": null + }, + { + "description": "Reach a 30 day streak", + "id": "streak-30", + "progress": 1, + "reward": 400, + "target": 30, + "title": "Month of Mondays", + "unlocked": null + }, + { + "description": "Reach a 60 day streak", + "id": "streak-60", + "progress": 1, + "reward": 500, + "target": 60, + "title": "Habit Formed", + "unlocked": null + }, + { + "description": "Reach a 100 day streak", + "id": "streak-100", + "progress": 1, + "reward": 600, + "target": 100, + "title": "Centurion", + "unlocked": null + }, + { + "description": "Reach a 200 day streak", + "id": "streak-200", + "progress": 1, + "reward": 700, + "target": 200, + "title": "Unbreakable", + "unlocked": null + }, + { + "description": "Reach a 365 day streak", + "id": "streak-365", + "progress": 1, + "reward": 800, + "target": 365, + "title": "Orbit Complete", + "unlocked": null + }, + { + "description": "Reach a 500 day streak", + "id": "streak-500", + "progress": 1, + "reward": 900, + "target": 500, + "title": "Five Hundred Sunrises", + "unlocked": null + }, + { + "description": "Reach a 730 day streak", + "id": "streak-730", + "progress": 1, + "reward": 1000, + "target": 730, + "title": "Two Orbits", + "unlocked": null + }, + { + "description": "Reach a 1000 day streak", + "id": "streak-1000", + "progress": 1, + "reward": 1100, + "target": 1000, + "title": "Thousand Days", + "unlocked": null + }, + { + "description": "Reach a 1500 day streak", + "id": "streak-1500", + "progress": 1, + "reward": 1200, + "target": 1500, + "title": "Unstoppable", + "unlocked": null + }, + { + "description": "Reach a 2000 day streak", + "id": "streak-2000", + "progress": 1, + "reward": 1300, + "target": 2000, + "title": "Perennial", + "unlocked": null + }, + { + "description": "Review 100 cards in one day", + "id": "dayreviews-100", + "progress": 5, + "reward": 100, + "target": 100, + "title": "Busy Day", + "unlocked": null + }, + { + "description": "Review 250 cards in one day", + "id": "dayreviews-250", + "progress": 5, + "reward": 200, + "target": 250, + "title": "Marathon", + "unlocked": null + }, + { + "description": "Review 500 cards in one day", + "id": "dayreviews-500", + "progress": 5, + "reward": 300, + "target": 500, + "title": "Ultramarathon", + "unlocked": null + }, + { + "description": "Review 750 cards in one day", + "id": "dayreviews-750", + "progress": 5, + "reward": 400, + "target": 750, + "title": "Iron Lung", + "unlocked": null + }, + { + "description": "Review 1000 cards in one day", + "id": "dayreviews-1000", + "progress": 5, + "reward": 500, + "target": 1000, + "title": "Thousand in a Day", + "unlocked": null + }, + { + "description": "Review 1500 cards in one day", + "id": "dayreviews-1500", + "progress": 5, + "reward": 600, + "target": 1500, + "title": "Grinder", + "unlocked": null + }, + { + "description": "Review 2000 cards in one day", + "id": "dayreviews-2000", + "progress": 5, + "reward": 700, + "target": 2000, + "title": "Madness", + "unlocked": null + }, + { + "description": "Reach a 50 card combo", + "id": "combo-50", + "progress": 5, + "reward": 100, + "target": 50, + "title": "In the Zone", + "unlocked": null + }, + { + "description": "Reach a 100 card combo", + "id": "combo-100", + "progress": 5, + "reward": 200, + "target": 100, + "title": "Flow State", + "unlocked": null + }, + { + "description": "Reach a 250 card combo", + "id": "combo-250", + "progress": 5, + "reward": 300, + "target": 250, + "title": "Trance", + "unlocked": null + }, + { + "description": "Reach a 500 card combo", + "id": "combo-500", + "progress": 5, + "reward": 400, + "target": 500, + "title": "Zen", + "unlocked": null + }, + { + "description": "Reach a 1000 card combo", + "id": "combo-1000", + "progress": 5, + "reward": 500, + "target": 1000, + "title": "Nirvana", + "unlocked": null + }, + { + "description": "Study for 10 hours in total", + "id": "hours-10", + "progress": 0, + "reward": 100, + "target": 10, + "title": "Ten Hours In", + "unlocked": null + }, + { + "description": "Study for 50 hours in total", + "id": "hours-50", + "progress": 0, + "reward": 200, + "target": 50, + "title": "Fifty Hours In", + "unlocked": null + }, + { + "description": "Study for 100 hours in total", + "id": "hours-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Hundred Hours In", + "unlocked": null + }, + { + "description": "Study for 500 hours in total", + "id": "hours-500", + "progress": 0, + "reward": 400, + "target": 500, + "title": "Scholar", + "unlocked": null + }, + { + "description": "Study for 1000 hours in total", + "id": "hours-1000", + "progress": 0, + "reward": 500, + "target": 1000, + "title": "Thousand Hours", + "unlocked": null + }, + { + "description": "Study for 2000 hours in total", + "id": "hours-2000", + "progress": 0, + "reward": 600, + "target": 2000, + "title": "Two Thousand Hours", + "unlocked": null + }, + { + "description": "Study for 5000 hours in total", + "id": "hours-5000", + "progress": 0, + "reward": 700, + "target": 5000, + "title": "Lifer", + "unlocked": null + }, + { + "description": "Review 100 mature cards", + "id": "mature-100", + "progress": 0, + "reward": 100, + "target": 100, + "title": "Long Term", + "unlocked": null + }, + { + "description": "Review 1000 mature cards", + "id": "mature-1000", + "progress": 0, + "reward": 200, + "target": 1000, + "title": "Deep Roots", + "unlocked": null + }, + { + "description": "Review 10000 mature cards", + "id": "mature-10000", + "progress": 0, + "reward": 300, + "target": 10000, + "title": "Old Growth", + "unlocked": null + }, + { + "description": "Review 25000 mature cards", + "id": "mature-25000", + "progress": 0, + "reward": 400, + "target": 25000, + "title": "Ancient Forest", + "unlocked": null + }, + { + "description": "Review 50000 mature cards", + "id": "mature-50000", + "progress": 0, + "reward": 500, + "target": 50000, + "title": "Redwood", + "unlocked": null + }, + { + "description": "Review 100000 mature cards", + "id": "mature-100000", + "progress": 0, + "reward": 600, + "target": 100000, + "title": "Bedrock", + "unlocked": null + }, + { + "description": "Learn 100 new cards", + "id": "newcards-100", + "progress": 0, + "reward": 100, + "target": 100, + "title": "Collector", + "unlocked": null + }, + { + "description": "Learn 1000 new cards", + "id": "newcards-1000", + "progress": 0, + "reward": 200, + "target": 1000, + "title": "Curator", + "unlocked": null + }, + { + "description": "Learn 5000 new cards", + "id": "newcards-5000", + "progress": 0, + "reward": 300, + "target": 5000, + "title": "Archivist", + "unlocked": null + }, + { + "description": "Learn 10000 new cards", + "id": "newcards-10000", + "progress": 0, + "reward": 400, + "target": 10000, + "title": "Encyclopedist", + "unlocked": null + }, + { + "description": "Learn 25000 new cards", + "id": "newcards-25000", + "progress": 0, + "reward": 500, + "target": 25000, + "title": "Lexicographer", + "unlocked": null + }, + { + "description": "Learn 50000 new cards", + "id": "newcards-50000", + "progress": 0, + "reward": 600, + "target": 50000, + "title": "Polymath", + "unlocked": null + }, + { + "description": "Complete 10 quests", + "id": "quests-10", + "progress": 1, + "reward": 100, + "target": 10, + "title": "Adventurer", + "unlocked": null + }, + { + "description": "Complete 50 quests", + "id": "quests-50", + "progress": 1, + "reward": 200, + "target": 50, + "title": "Quest Hound", + "unlocked": null + }, + { + "description": "Complete 200 quests", + "id": "quests-200", + "progress": 1, + "reward": 300, + "target": 200, + "title": "Completionist", + "unlocked": null + }, + { + "description": "Complete 500 quests", + "id": "quests-500", + "progress": 1, + "reward": 400, + "target": 500, + "title": "Quest Master", + "unlocked": null + }, + { + "description": "Complete 1000 quests", + "id": "quests-1000", + "progress": 1, + "reward": 500, + "target": 1000, + "title": "Legend", + "unlocked": null + }, + { + "description": "Complete 2500 quests", + "id": "quests-2500", + "progress": 1, + "reward": 600, + "target": 2500, + "title": "Mythic", + "unlocked": null + }, + { + "description": "Complete every quest on 7 days", + "id": "perfectdays-7", + "progress": 0, + "reward": 100, + "target": 7, + "title": "Perfect Week", + "unlocked": null + }, + { + "description": "Complete every quest on 30 days", + "id": "perfectdays-30", + "progress": 0, + "reward": 200, + "target": 30, + "title": "Perfect Month", + "unlocked": null + }, + { + "description": "Complete every quest on 100 days", + "id": "perfectdays-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Perfectionist", + "unlocked": null + }, + { + "description": "Complete every quest on 365 days", + "id": "perfectdays-365", + "progress": 0, + "reward": 400, + "target": 365, + "title": "Flawless Year", + "unlocked": null + }, + { + "description": "Study before 7am on 5 days", + "id": "earlydays-5", + "progress": 0, + "reward": 100, + "target": 5, + "title": "Early Bird", + "unlocked": null + }, + { + "description": "Study before 7am on 30 days", + "id": "earlydays-30", + "progress": 0, + "reward": 200, + "target": 30, + "title": "Dawn Patrol", + "unlocked": null + }, + { + "description": "Study before 7am on 100 days", + "id": "earlydays-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Sunrise Scholar", + "unlocked": null + }, + { + "description": "Study after 11pm on 5 days", + "id": "latedays-5", + "progress": 0, + "reward": 100, + "target": 5, + "title": "Night Owl", + "unlocked": null + }, + { + "description": "Study after 11pm on 30 days", + "id": "latedays-30", + "progress": 0, + "reward": 200, + "target": 30, + "title": "Nocturnal", + "unlocked": null + }, + { + "description": "Study after 11pm on 100 days", + "id": "latedays-100", + "progress": 0, + "reward": 300, + "target": 100, + "title": "Creature of the Night", + "unlocked": null + }, + { + "description": "Study on 30 different days", + "id": "daysactive-30", + "progress": 1, + "reward": 100, + "target": 30, + "title": "Regular", + "unlocked": null + }, + { + "description": "Study on 100 different days", + "id": "daysactive-100", + "progress": 1, + "reward": 200, + "target": 100, + "title": "Hundred Days", + "unlocked": null + }, + { + "description": "Study on 365 different days", + "id": "daysactive-365", + "progress": 1, + "reward": 300, + "target": 365, + "title": "A Year of Days", + "unlocked": null + }, + { + "description": "Study on 730 different days", + "id": "daysactive-730", + "progress": 1, + "reward": 400, + "target": 730, + "title": "Two Years of Days", + "unlocked": null + }, + { + "description": "Study on 1000 different days", + "id": "daysactive-1000", + "progress": 1, + "reward": 500, + "target": 1000, + "title": "Thousand Days Studied", + "unlocked": null + }, + { + "description": "Study on 2000 different days", + "id": "daysactive-2000", + "progress": 1, + "reward": 600, + "target": 2000, + "title": "Two Thousand Days Studied", + "unlocked": null + }, + { + "description": "Relearn 100 forgotten cards", + "id": "relearns-100", + "progress": 0, + "reward": 100, + "target": 100, + "title": "Second Chance", + "unlocked": null + }, + { + "description": "Relearn 1000 forgotten cards", + "id": "relearns-1000", + "progress": 0, + "reward": 200, + "target": 1000, + "title": "Persistence", + "unlocked": null + }, + { + "description": "Relearn 10000 forgotten cards", + "id": "relearns-10000", + "progress": 0, + "reward": 300, + "target": 10000, + "title": "Never Give Up", + "unlocked": null + }, + { + "description": "Relearn 50000 forgotten cards", + "id": "relearns-50000", + "progress": 0, + "reward": 400, + "target": 50000, + "title": "Sisyphus", + "unlocked": null + }, + { + "description": "Recall a card last seen 180 days before", + "id": "maxinterval-180", + "progress": 1, + "reward": 100, + "target": 180, + "title": "Half-Year Memory", + "unlocked": null + }, + { + "description": "Recall a card last seen 365 days before", + "id": "maxinterval-365", + "progress": 1, + "reward": 200, + "target": 365, + "title": "Year-Old Memory", + "unlocked": null + }, + { + "description": "Recall a card last seen 1825 days before", + "id": "maxinterval-1825", + "progress": 1, + "reward": 300, + "target": 1825, + "title": "Five-Year Memory", + "unlocked": null + }, + { + "description": "Recall a card last seen 3650 days before", + "id": "maxinterval-3650", + "progress": 1, + "reward": 400, + "target": 3650, + "title": "Decade Memory", + "unlocked": null + }, + { + "description": "Review 1000 different cards", + "id": "distinctcards-1000", + "progress": 5, + "reward": 100, + "target": 1000, + "title": "Wide Net", + "unlocked": null + }, + { + "description": "Review 5000 different cards", + "id": "distinctcards-5000", + "progress": 5, + "reward": 200, + "target": 5000, + "title": "Big Deck", + "unlocked": null + }, + { + "description": "Review 20000 different cards", + "id": "distinctcards-20000", + "progress": 5, + "reward": 300, + "target": 20000, + "title": "Vast Library", + "unlocked": null + }, + { + "description": "Review 50000 different cards", + "id": "distinctcards-50000", + "progress": 5, + "reward": 400, + "target": 50000, + "title": "Everything Everywhere", + "unlocked": null + }, + { + "description": "Study 60 minutes in one sitting", + "id": "longestsession-60", + "progress": 0, + "reward": 100, + "target": 60, + "title": "Deep Work", + "unlocked": null + }, + { + "description": "Study 120 minutes in one sitting", + "id": "longestsession-120", + "progress": 0, + "reward": 200, + "target": 120, + "title": "Two-Hour Sitting", + "unlocked": null + }, + { + "description": "Study 240 minutes in one sitting", + "id": "longestsession-240", + "progress": 0, + "reward": 300, + "target": 240, + "title": "Iron Chair", + "unlocked": null + }, + { + "description": "Study in 5 separate sessions in one day", + "id": "maxsessions-5", + "progress": 1, + "reward": 100, + "target": 5, + "title": "Snacker", + "unlocked": null + }, + { + "description": "Study in 10 separate sessions in one day", + "id": "maxsessions-10", + "progress": 1, + "reward": 200, + "target": 10, + "title": "Grazer", + "unlocked": null + }, + { + "description": "Study in 20 separate sessions in one day", + "id": "maxsessions-20", + "progress": 1, + "reward": 300, + "target": 20, + "title": "Can't Stop", + "unlocked": null + }, + { + "description": "Review 1000 cards in one calendar month", + "id": "bestmonth-1000", + "progress": 5, + "reward": 100, + "target": 1000, + "title": "Busy Month", + "unlocked": null + }, + { + "description": "Review 3000 cards in one calendar month", + "id": "bestmonth-3000", + "progress": 5, + "reward": 200, + "target": 3000, + "title": "Big Month", + "unlocked": null + }, + { + "description": "Review 10000 cards in one calendar month", + "id": "bestmonth-10000", + "progress": 5, + "reward": 300, + "target": 10000, + "title": "Monster Month", + "unlocked": null + }, + { + "description": "Review 20000 cards in one calendar month", + "id": "bestmonth-20000", + "progress": 5, + "reward": 400, + "target": 20000, + "title": "Month of Madness", + "unlocked": null + }, + { + "description": "Study on 10 Saturdays or Sundays", + "id": "weekenddays-10", + "progress": 0, + "reward": 100, + "target": 10, + "title": "Weekend Warrior", + "unlocked": null + }, + { + "description": "Study on 52 Saturdays or Sundays", + "id": "weekenddays-52", + "progress": 0, + "reward": 200, + "target": 52, + "title": "Weekend Regular", + "unlocked": null + }, + { + "description": "Study on 200 Saturdays or Sundays", + "id": "weekenddays-200", + "progress": 0, + "reward": 300, + "target": 200, + "title": "No Days Off", + "unlocked": null + }, + { + "description": "Come back after a month away", + "id": "comebacks-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Comeback", + "unlocked": null + }, + { + "description": "Come back after a month away 3 times", + "id": "comebacks-3", + "progress": 0, + "reward": 200, + "target": 3, + "title": "Phoenix", + "unlocked": null + }, + { + "description": "Have a streak freeze save you 1 times", + "id": "freezesused-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Saved by the Ice", + "unlocked": null + }, + { + "description": "Have a streak freeze save you 10 times", + "id": "freezesused-10", + "progress": 0, + "reward": 200, + "target": 10, + "title": "Glacier", + "unlocked": null + }, + { + "description": "Have a streak freeze save you 50 times", + "id": "freezesused-50", + "progress": 0, + "reward": 300, + "target": 50, + "title": "Ice Age", + "unlocked": null + }, + { + "description": "Review 100+ cards in a day averaging under 5 seconds, 1 times", + "id": "fastdays-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Speed Demon", + "unlocked": null + }, + { + "description": "Review 100+ cards in a day averaging under 5 seconds, 10 times", + "id": "fastdays-10", + "progress": 0, + "reward": 200, + "target": 10, + "title": "Lightning", + "unlocked": null + }, + { + "description": "Review 100+ cards in a day averaging under 5 seconds, 50 times", + "id": "fastdays-50", + "progress": 0, + "reward": 300, + "target": 50, + "title": "Quicksilver", + "unlocked": null + }, + { + "description": "Study on New Year's Day 1 times", + "id": "newyeardays-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "New Year, Same Me", + "unlocked": null + }, + { + "description": "Study on New Year's Day 3 times", + "id": "newyeardays-3", + "progress": 0, + "reward": 200, + "target": 3, + "title": "Tradition", + "unlocked": null + }, + { + "description": "Study on the anniversary of your first review 1 times", + "id": "anniversaries-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "Anniversary", + "unlocked": null + }, + { + "description": "Study on the anniversary of your first review 5 times", + "id": "anniversaries-5", + "progress": 0, + "reward": 200, + "target": 5, + "title": "Old Friends", + "unlocked": null + }, + { + "description": "Study on the anniversary of your first review 10 times", + "id": "anniversaries-10", + "progress": 0, + "reward": 300, + "target": 10, + "title": "Lifelong", + "unlocked": null + }, + { + "description": "Keep going for 1 years since your first review", + "id": "years-1", + "progress": 0, + "reward": 100, + "target": 1, + "title": "One Year In", + "unlocked": null + }, + { + "description": "Keep going for 3 years since your first review", + "id": "years-3", + "progress": 0, + "reward": 200, + "target": 3, + "title": "Three Years In", + "unlocked": null + }, + { + "description": "Keep going for 5 years since your first review", + "id": "years-5", + "progress": 0, + "reward": 300, + "target": 5, + "title": "Veteran", + "unlocked": null + }, + { + "description": "Keep going for 10 years since your first review", + "id": "years-10", + "progress": 0, + "reward": 400, + "target": 10, + "title": "Decade of Anki", + "unlocked": null + } + ], + "announced": [], + "at_risk": false, + "conquests": [], + "day_ends_at": 1793246400000, + "display": "Hill", + "feedback": { + "headlines": [], + "status": null + }, + "freeze_earned_today": false, + "freezes": 0, + "freezes_enabled": false, + "goal": { + "day": 20754, + "mode": "backlog", + "progress": null, + "reached": false, + "since": 20754, + "value": 30 + }, + "heatmap": [ + { + "date": "2026-04-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-05-31", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-06-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-07-31", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-08-31", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-28", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-29", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-09-30", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-01", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-02", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-03", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-04", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-05", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-06", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-07", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-08", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-09", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-10", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-11", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-12", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-13", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-14", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-15", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-16", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-17", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-18", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-19", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-20", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-21", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-22", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-23", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-24", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-25", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-26", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-27", + "frozen": false, + "reviews": 0, + "xp": 0 + }, + { + "date": "2026-10-28", + "frozen": false, + "reviews": 5, + "xp": 101 + } + ], + "last_review_id": 1793182204000, + "level": 2, + "lifetime": { + "best_combo": 5, + "best_day": 5, + "best_streak": 1, + "best_streak_at": 1793160000000, + "conquered": 0, + "days_active": 1, + "first_day_at": 1793160000000, + "hours": 0, + "quests": 1, + "reviews": 5 + }, + "local_hour": 10, + "periods": { + "all": 101, + "day": 101, + "hour": 51, + "month": 101, + "week": 101, + "year": 101 + }, + "quests": [ + { + "done": false, + "progress": 5, + "reward": 50, + "target": 15, + "title": "Review 15 cards" + }, + { + "done": true, + "progress": 5, + "reward": 50, + "target": 5, + "title": "Review 5 cards before noon" + }, + { + "done": false, + "progress": 1, + "reward": 50, + "target": 2, + "title": "Study in 2 separate sessions" + } + ], + "records": { + "day": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + }, + "hour": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 51 + }, + "month": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + }, + "week": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + }, + "year": { + "at": 1793182200000, + "reviews": 5, + "until": 1793182204000, + "xp": 101 + } + }, + "stored_freezes": 0, + "streak": 1, + "streak_state": "studied", + "streak_warning": null, + "today": { + "current_combo": 5, + "max_combo": 5, + "minutes": 0, + "new_cards": 0, + "reviews": 5, + "streak_minutes": 0, + "xp": 101 + }, + "user": "hill", + "week_xp": 101, + "xp_for_next": 280, + "xp_into_level": 1, + "xp_total": 101 +} diff --git a/AnkiDroid/src/test/resources/ankiquest-api/wins.json b/AnkiDroid/src/test/resources/ankiquest-api/wins.json new file mode 100644 index 000000000000..8fdc86eb1654 --- /dev/null +++ b/AnkiDroid/src/test/resources/ankiquest-api/wins.json @@ -0,0 +1,26 @@ +{ + "wins": [ + { + "art": "celebrate", + "body": "5 cards today in 0 minutes.", + "created_at": 1793183400, + "day": 20754, + "id": 1, + "key": "goal:20754", + "kind": "daily_goal", + "link": { + "image": "https://anki.example.test/w/example-link-1.png", + "url": "https://anki.example.test/w/example-link-1" + }, + "policy": "ask", + "shared_with_friends": true, + "stats": { + "level": 2, + "minutes_today": 1, + "reviews_today": 5, + "streak": 1 + }, + "title": "Daily goal reached!" + } + ] +}