Skip to content

Commit 6e5b01f

Browse files
Merge pull request #74 from kirill-markin/codex/local-numeric-due-at
[codex] Migrate local due scheduling to numeric keys
2 parents 110c7cc + 61ca1fb commit 6e5b01f

32 files changed

Lines changed: 2004 additions & 426 deletions

apps/android/data/local/src/androidTest/java/com/flashcardsopensourceapp/data/local/AppDatabaseMigrationTest.kt

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import androidx.sqlite.db.SupportSQLiteOpenHelper
88
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
99
import androidx.test.core.app.ApplicationProvider
1010
import androidx.test.ext.junit.runners.AndroidJUnit4
11+
import com.flashcardsopensourceapp.data.local.database.cardsReviewQueueIndexName
1112
import com.flashcardsopensourceapp.data.local.database.migration10To11
1213
import com.flashcardsopensourceapp.data.local.database.migration12To13
14+
import com.flashcardsopensourceapp.data.local.database.migration13To14
1315
import com.flashcardsopensourceapp.data.local.database.migration5To6
1416
import com.flashcardsopensourceapp.data.local.database.migration9To10
1517
import org.junit.After
@@ -274,6 +276,27 @@ class AppDatabaseMigrationTest {
274276
}
275277
}
276278

279+
@Test
280+
fun migration13To14AddsCardsReviewQueueIndex() {
281+
val context = ApplicationProvider.getApplicationContext<Context>()
282+
createVersion13Database(context = context)
283+
284+
val openHelper = openDatabaseAtVersion(
285+
context = context,
286+
name = targetedMigrationDatabaseName,
287+
version = 13
288+
)
289+
val database = openHelper.writableDatabase
290+
291+
try {
292+
migration13To14.migrate(database)
293+
assertCardsReviewQueueIndexExists(database = database)
294+
} finally {
295+
database.close()
296+
openHelper.close()
297+
}
298+
}
299+
277300
private fun createVersion5Database(context: Context) {
278301
val databaseFile = context.getDatabasePath(targetedMigrationDatabaseName)
279302
if (databaseFile.exists()) {
@@ -634,6 +657,47 @@ class AppDatabaseMigrationTest {
634657
sqliteDatabase.close()
635658
}
636659

660+
private fun createVersion13Database(context: Context) {
661+
val databaseFile = context.getDatabasePath(targetedMigrationDatabaseName)
662+
if (databaseFile.exists()) {
663+
databaseFile.delete()
664+
}
665+
databaseFile.parentFile?.mkdirs()
666+
667+
val sqliteDatabase = SQLiteDatabase.openOrCreateDatabase(databaseFile, null)
668+
sqliteDatabase.execSQL(
669+
"CREATE TABLE workspaces (workspaceId TEXT NOT NULL PRIMARY KEY, name TEXT NOT NULL, createdAtMillis INTEGER NOT NULL)"
670+
)
671+
sqliteDatabase.execSQL(
672+
"""
673+
CREATE TABLE cards (
674+
cardId TEXT NOT NULL PRIMARY KEY,
675+
workspaceId TEXT NOT NULL,
676+
frontText TEXT NOT NULL,
677+
backText TEXT NOT NULL,
678+
effortLevel TEXT NOT NULL,
679+
dueAtMillis INTEGER,
680+
createdAtMillis INTEGER NOT NULL,
681+
updatedAtMillis INTEGER NOT NULL,
682+
reps INTEGER NOT NULL,
683+
lapses INTEGER NOT NULL,
684+
fsrsCardState TEXT NOT NULL,
685+
fsrsStepIndex INTEGER,
686+
fsrsStability REAL,
687+
fsrsDifficulty REAL,
688+
fsrsLastReviewedAtMillis INTEGER,
689+
fsrsScheduledDays INTEGER,
690+
deletedAtMillis INTEGER,
691+
FOREIGN KEY(workspaceId) REFERENCES workspaces(workspaceId) ON DELETE CASCADE
692+
)
693+
""".trimIndent()
694+
)
695+
sqliteDatabase.execSQL("CREATE INDEX index_cards_workspaceId ON cards(workspaceId)")
696+
697+
sqliteDatabase.version = 13
698+
sqliteDatabase.close()
699+
}
700+
637701
private fun openDatabaseAtVersion(
638702
context: Context,
639703
name: String,
@@ -745,4 +809,24 @@ class AppDatabaseMigrationTest {
745809

746810
assertTrue(hasReviewedAtIndex)
747811
}
812+
813+
private fun assertCardsReviewQueueIndexExists(database: SupportSQLiteDatabase) {
814+
assertEquals(
815+
listOf("workspaceId", "dueAtMillis", "createdAtMillis", "cardId"),
816+
readIndexColumns(database = database, indexName = cardsReviewQueueIndexName)
817+
)
818+
}
819+
820+
private fun readIndexColumns(database: SupportSQLiteDatabase, indexName: String): List<String> {
821+
return database.query(SimpleSQLiteQuery("PRAGMA index_info('$indexName')")).use { cursor ->
822+
val nameColumnIndex = cursor.getColumnIndexOrThrow("name")
823+
val columns = mutableListOf<String>()
824+
825+
while (cursor.moveToNext()) {
826+
columns.add(cursor.getString(nameColumnIndex))
827+
}
828+
829+
columns.toList()
830+
}
831+
}
748832
}

apps/android/data/local/src/main/java/com/flashcardsopensourceapp/data/local/database/AppDatabase.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ private const val androidInstallationId: String = "android-installation"
2929
ProgressReviewHistoryStateEntity::class,
3030
ProgressLocalCacheStateEntity::class
3131
],
32-
version = 13,
32+
version = 14,
3333
exportSchema = false
3434
)
3535
@TypeConverters(DatabaseTypeConverters::class)
@@ -71,7 +71,8 @@ fun createAppDatabaseMigrations(): Array<Migration> {
7171
migration9To10,
7272
migration10To11,
7373
migration11To12,
74-
migration12To13
74+
migration12To13,
75+
migration13To14
7576
)
7677
}
7778

@@ -675,3 +676,14 @@ val migration12To13: Migration = object : Migration(12, 13) {
675676
db.execSQL("ALTER TABLE sync_state ADD COLUMN pendingReviewHistoryImport INTEGER NOT NULL DEFAULT 0")
676677
}
677678
}
679+
680+
val migration13To14: Migration = object : Migration(13, 14) {
681+
override fun migrate(db: SupportSQLiteDatabase) {
682+
db.execSQL(
683+
"""
684+
CREATE INDEX IF NOT EXISTS $cardsReviewQueueIndexName
685+
ON cards(workspaceId, dueAtMillis, createdAtMillis, cardId)
686+
""".trimIndent()
687+
)
688+
}
689+
}

apps/android/data/local/src/main/java/com/flashcardsopensourceapp/data/local/database/DatabaseEntities.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import com.flashcardsopensourceapp.data.local.model.EffortLevel
1111
import com.flashcardsopensourceapp.data.local.model.FsrsCardState
1212
import com.flashcardsopensourceapp.data.local.model.ReviewRating
1313

14+
internal const val cardsReviewQueueIndexName: String = "index_cards_workspaceId_dueAtMillis_createdAtMillis_cardId"
15+
1416
@Entity(tableName = "workspaces")
1517
data class WorkspaceEntity(
1618
@PrimaryKey val workspaceId: String,
@@ -62,7 +64,13 @@ data class DeckEntity(
6264
onDelete = ForeignKey.CASCADE
6365
)
6466
],
65-
indices = [Index("workspaceId")]
67+
indices = [
68+
Index("workspaceId"),
69+
Index(
70+
value = ["workspaceId", "dueAtMillis", "createdAtMillis", "cardId"],
71+
name = cardsReviewQueueIndexName
72+
)
73+
]
6674
)
6775
data class CardEntity(
6876
@PrimaryKey val cardId: String,

apps/android/data/local/src/main/java/com/flashcardsopensourceapp/data/local/model/CloudSupport.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import java.util.Locale
88

99
private const val flashcardsOfficialApiBaseUrl: String = "https://api.flashcards-open-source-app.com/v1"
1010
private const val flashcardsOfficialAuthBaseUrl: String = "https://auth.flashcards-open-source-app.com"
11+
private val canonicalIsoTimestampFormatter: DateTimeFormatter = DateTimeFormatter
12+
.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
13+
.withZone(ZoneOffset.UTC)
1114

1215
fun makeOfficialCloudServiceConfiguration(): CloudServiceConfiguration {
1316
return CloudServiceConfiguration(
@@ -88,7 +91,5 @@ fun parseIsoTimestamp(value: String): Long {
8891
}
8992

9093
fun formatIsoTimestamp(timestampMillis: Long): String {
91-
return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(
92-
Instant.ofEpochMilli(timestampMillis).atOffset(ZoneOffset.UTC)
93-
)
94+
return canonicalIsoTimestampFormatter.format(Instant.ofEpochMilli(timestampMillis))
9495
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.flashcardsopensourceapp.data.local.model
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Test
5+
6+
class CloudSupportTest {
7+
@Test
8+
fun formatIsoTimestampEmitsCanonicalUtcMilliseconds() {
9+
assertEquals(
10+
"2026-03-10T12:00:00.000Z",
11+
formatIsoTimestamp(timestampMillis = 1773144000000L)
12+
)
13+
assertEquals(
14+
"2026-03-10T12:00:00.100Z",
15+
formatIsoTimestamp(timestampMillis = 1773144000100L)
16+
)
17+
}
18+
}

0 commit comments

Comments
 (0)