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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,40 @@ data class ReviewLogDto(
)
}
}

// MARK: - Streak state (single doc per user: users/{uid}/streakState/current)

data class StreakStateDto(
@DocumentId var id: String? = "current",
@get:PropertyName("freeze_tokens") @set:PropertyName("freeze_tokens") var freezeTokens: Int = 0,
@get:PropertyName("frozen_days") @set:PropertyName("frozen_days") var frozenDays: List<Long> = emptyList(),
@get:PropertyName("freezes_awarded_for_run") @set:PropertyName("freezes_awarded_for_run") var freezesAwardedForRun: Int = 0,
@get:PropertyName("last_reconciled_day") @set:PropertyName("last_reconciled_day") var lastReconciledDay: Long = 0,
@get:PropertyName("last_repair_day") @set:PropertyName("last_repair_day") var lastRepairDay: Long = 0,
@get:PropertyName("last_modified") @set:PropertyName("last_modified") var lastModified: Timestamp = Timestamp(Date(0)),
) {
fun lastModifiedMillis(): Long = lastModified.toMillis()

fun toEntity(dirty: Boolean) = nart.simpleanki.core.data.local.StreakStateEntity(
id = "current",
freezeTokens = freezeTokens,
frozenDays = frozenDays.sorted().joinToString(","),
freezesAwardedForRun = freezesAwardedForRun,
lastReconciledDay = lastReconciledDay,
lastRepairDay = lastRepairDay,
lastModified = lastModified.toMillis(),
dirty = dirty,
)

companion object {
fun fromEntity(e: nart.simpleanki.core.data.local.StreakStateEntity) = StreakStateDto(
id = "current",
freezeTokens = e.freezeTokens,
frozenDays = e.frozenDays.split(",").filter { it.isNotBlank() }.map { it.toLong() },
freezesAwardedForRun = e.freezesAwardedForRun,
lastReconciledDay = e.lastReconciledDay,
lastRepairDay = e.lastRepairDay,
lastModified = e.lastModified.toTimestamp(),
)
}
}
18 changes: 16 additions & 2 deletions app/src/main/java/nart/simpleanki/core/data/local/AzriDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ import nart.simpleanki.core.data.local.dao.CardDao
import nart.simpleanki.core.data.local.dao.DeckDao
import nart.simpleanki.core.data.local.dao.FolderDao
import nart.simpleanki.core.data.local.dao.ReviewLogDao
import nart.simpleanki.core.data.local.dao.StreakStateDao

@Database(
entities = [CardEntity::class, DeckEntity::class, FolderEntity::class, ReviewLogEntity::class],
version = 2,
entities = [CardEntity::class, DeckEntity::class, FolderEntity::class, ReviewLogEntity::class, StreakStateEntity::class],
version = 3,
exportSchema = false,
)
abstract class AzriDatabase : RoomDatabase() {
abstract fun cardDao(): CardDao
abstract fun deckDao(): DeckDao
abstract fun folderDao(): FolderDao
abstract fun reviewLogDao(): ReviewLogDao
abstract fun streakStateDao(): StreakStateDao
}

/**
Expand All @@ -40,3 +42,15 @@ val MIGRATION_1_2 = object : Migration(1, 2) {
db.execSQL("CREATE INDEX IF NOT EXISTS `index_review_logs_review` ON `review_logs` (`review`)")
}
}

val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `streak_state` (" +
"`id` TEXT NOT NULL, `freezeTokens` INTEGER NOT NULL, `frozenDays` TEXT NOT NULL, " +
"`freezesAwardedForRun` INTEGER NOT NULL, `lastReconciledDay` INTEGER NOT NULL, " +
"`lastRepairDay` INTEGER NOT NULL, `lastModified` INTEGER NOT NULL, " +
"`dirty` INTEGER NOT NULL, PRIMARY KEY(`id`))",
)
}
}
13 changes: 13 additions & 0 deletions app/src/main/java/nart/simpleanki/core/data/local/RoomEntities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,16 @@ data class ReviewLogEntity(
val review: Long,
val dirty: Boolean = true,
)

@Entity(tableName = "streak_state")
data class StreakStateEntity(
@PrimaryKey val id: String = "current",
val freezeTokens: Int,
/** Civil-day indices covered by a freeze/repair, sorted, comma-separated (empty string = none). */
val frozenDays: String,
val freezesAwardedForRun: Int,
val lastReconciledDay: Long,
val lastRepairDay: Long,
val lastModified: Long,
val dirty: Boolean = true,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package nart.simpleanki.core.data.local

import nart.simpleanki.core.domain.streak.StreakState

fun StreakStateEntity.toDomain(): StreakState = StreakState(
freezeTokens = freezeTokens,
frozenDays = frozenDays.split(",").filter { it.isNotBlank() }.map { it.toLong() }.toSet(),
freezesAwardedForRun = freezesAwardedForRun,
lastReconciledDay = lastReconciledDay,
lastRepairDay = lastRepairDay,
)

fun StreakState.toEntity(lastModified: Long, dirty: Boolean): StreakStateEntity = StreakStateEntity(
id = "current",
freezeTokens = freezeTokens,
frozenDays = frozenDays.sorted().joinToString(","),
freezesAwardedForRun = freezesAwardedForRun,
lastReconciledDay = lastReconciledDay,
lastRepairDay = lastRepairDay,
lastModified = lastModified,
dirty = dirty,
)
19 changes: 19 additions & 0 deletions app/src/main/java/nart/simpleanki/core/data/local/dao/Daos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import nart.simpleanki.core.data.local.CardEntity
import nart.simpleanki.core.data.local.DeckEntity
import nart.simpleanki.core.data.local.FolderEntity
import nart.simpleanki.core.data.local.ReviewLogEntity
import nart.simpleanki.core.data.local.StreakStateEntity

@Dao
interface FolderDao {
Expand Down Expand Up @@ -97,3 +98,21 @@ interface ReviewLogDao {
@Query("SELECT * FROM review_logs ORDER BY review")
fun observeAll(): Flow<List<ReviewLogEntity>>
}

@Dao
interface StreakStateDao {
@Query("SELECT * FROM streak_state WHERE id = 'current'")
fun observe(): Flow<StreakStateEntity?>

@Query("SELECT * FROM streak_state WHERE id = 'current'")
suspend fun get(): StreakStateEntity?

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entity: StreakStateEntity)

@Query("SELECT * FROM streak_state WHERE dirty = 1")
suspend fun getDirty(): StreakStateEntity?

@Query("UPDATE streak_state SET dirty = 0 WHERE id = 'current' AND lastModified = :lastModified")
suspend fun clearDirty(lastModified: Long)
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import nart.simpleanki.core.data.local.dao.CardDao
import nart.simpleanki.core.data.local.dao.DeckDao
import nart.simpleanki.core.data.local.dao.FolderDao
import nart.simpleanki.core.data.local.dao.ReviewLogDao
import nart.simpleanki.core.data.local.dao.StreakStateDao
import nart.simpleanki.core.data.local.toDomain
import nart.simpleanki.core.data.local.toEntity
import nart.simpleanki.core.domain.model.Card
import nart.simpleanki.core.domain.model.Deck
import nart.simpleanki.core.domain.model.Folder
import nart.simpleanki.core.domain.model.ReviewLog
import nart.simpleanki.core.domain.streak.StreakState
import java.util.UUID

/**
Expand Down Expand Up @@ -125,3 +127,17 @@ class ReviewLogRepository(

fun observeLogs(): Flow<List<ReviewLog>> = dao.observeAll().map { rows -> rows.map { it.toDomain() } }
}

class StreakStateRepository(
private val dao: StreakStateDao,
private val now: () -> Long = { System.currentTimeMillis() },
) {
fun observe(): Flow<StreakState> =
dao.observe().map { it?.toDomain() ?: StreakState() }

suspend fun get(): StreakState = dao.get()?.toDomain() ?: StreakState()

suspend fun update(state: StreakState) {
dao.upsert(state.toEntity(lastModified = now(), dirty = true))
}
}
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
package nart.simpleanki.core.data.repository

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import nart.simpleanki.core.domain.streak.Streak
import nart.simpleanki.core.domain.streak.StreakCalculator
import nart.simpleanki.core.domain.streak.localEpochDay
import java.util.TimeZone

/** Derives the study [Streak] from the review logs. Stateless — pure derivation, nothing stored. */
/** Derives the study [Streak] from the review logs, unioned with frozen days from [StreakStateRepository]. */
class StreakProvider(
private val reviewLogRepository: ReviewLogRepository,
private val streakStateRepository: StreakStateRepository,
private val now: () -> Long = { System.currentTimeMillis() },
private val timeZone: TimeZone = TimeZone.getDefault(),
) {
/** Live streak for the home header — reacts to new review logs. */
fun observeStreak(): Flow<Streak> =
reviewLogRepository.observeLogs().map { logs ->
combine(reviewLogRepository.observeLogs(), streakStateRepository.observe()) { logs, state ->
val days = logs.mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) }
days += state.frozenDays
StreakCalculator.compute(days, localEpochDay(now(), timeZone))
}

Expand All @@ -29,7 +31,8 @@ class StreakProvider(
val today = localEpochDay(now(), timeZone)
val days = reviewLogRepository.observeLogs().first()
.mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) }
.apply { add(today) }
days += streakStateRepository.get().frozenDays
days += today
return StreakCalculator.compute(days, today)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package nart.simpleanki.core.data.repository

import kotlinx.coroutines.flow.first
import nart.simpleanki.core.domain.streak.RepairOffer
import nart.simpleanki.core.domain.streak.StreakReconciler
import nart.simpleanki.core.domain.streak.localEpochDay
import java.util.TimeZone

/**
* Ties the pure [StreakReconciler] to persisted [StreakStateRepository] + review logs. Call
* [reconcile] on app foreground and after a session — never inside a Flow (it persists state).
*/
class StreakStateManager(
private val streakStateRepository: StreakStateRepository,
private val reviewLogRepository: ReviewLogRepository,
private val now: () -> Long = { System.currentTimeMillis() },
private val timeZone: TimeZone = TimeZone.getDefault(),
) {
private suspend fun reviewDays(): Set<Long> =
reviewLogRepository.observeLogs().first().mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) }

suspend fun reconcile() {
val today = localEpochDay(now(), timeZone)
val state = streakStateRepository.get()
val updated = StreakReconciler.reconcile(reviewDays(), state, today)
if (updated != state) streakStateRepository.update(updated)
}

/**
* Whether a free repair is available for yesterday's gap. Assumes [reconcile] has already run for
* today, so any auto-freeze that would already cover yesterday is reflected in the persisted state
* before eligibility is judged.
*
* [includeToday] forces today into the day set — for the post-session summary, so the offer is
* correct even though the per-rating review-log append is fire-and-forget and may not have landed
* in the logs yet.
*/
suspend fun repairOffer(includeToday: Boolean = false): RepairOffer? {
val today = localEpochDay(now(), timeZone)
val days = reviewDays()
val effective = if (includeToday) days + today else days
return StreakReconciler.repairEligibility(effective, streakStateRepository.get(), today)
}

/**
* Applies a repair, freezing yesterday's gap. Callers must serialize this with [reconcile]: the
* read-modify-write is not atomic. The intended call sites (app foreground + a button handler) are
* already sequential.
*/
suspend fun repair() {
val today = localEpochDay(now(), timeZone)
streakStateRepository.update(StreakReconciler.repair(streakStateRepository.get(), today))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import nart.simpleanki.core.data.firestore.CardDto
import nart.simpleanki.core.data.firestore.DeckDto
import nart.simpleanki.core.data.firestore.FolderDto
import nart.simpleanki.core.data.firestore.ReviewLogDto
import nart.simpleanki.core.data.firestore.StreakStateDto

/**
* Firestore-backed [RemoteSyncSource]. Collections mirror the iOS layout exactly:
Expand Down Expand Up @@ -42,6 +43,13 @@ class FirestoreSyncService(
override suspend fun pushReviewLogs(uid: String, dtos: List<ReviewLogDto>) =
push(uid, "reviewLogs", dtos) { it.id }

override suspend fun fetchStreakState(uid: String): StreakStateDto? =
col(uid, "streakState").document("current").get().await().toObject(StreakStateDto::class.java)

override suspend fun pushStreakState(uid: String, dto: StreakStateDto) {
col(uid, "streakState").document("current").set(dto).await()
}

private suspend fun <T : Any> push(uid: String, name: String, dtos: List<T>, id: (T) -> String?) {
if (dtos.isEmpty()) return
val batch = firestore.batch()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import nart.simpleanki.core.data.firestore.CardDto
import nart.simpleanki.core.data.firestore.DeckDto
import nart.simpleanki.core.data.firestore.FolderDto
import nart.simpleanki.core.data.firestore.ReviewLogDto
import nart.simpleanki.core.data.firestore.StreakStateDto

/**
* Remote sync seam over Firestore. Implemented by [FirestoreSyncService]; faked in tests.
Expand All @@ -21,4 +22,7 @@ interface RemoteSyncSource {

suspend fun fetchReviewLogs(uid: String): List<ReviewLogDto>
suspend fun pushReviewLogs(uid: String, dtos: List<ReviewLogDto>)

suspend fun fetchStreakState(uid: String): StreakStateDto?
suspend fun pushStreakState(uid: String, dto: StreakStateDto)
}
12 changes: 12 additions & 0 deletions app/src/main/java/nart/simpleanki/core/data/sync/SyncManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import nart.simpleanki.core.data.firestore.CardDto
import nart.simpleanki.core.data.firestore.DeckDto
import nart.simpleanki.core.data.firestore.FolderDto
import nart.simpleanki.core.data.firestore.ReviewLogDto
import nart.simpleanki.core.data.firestore.StreakStateDto
import nart.simpleanki.core.data.local.dao.CardDao
import nart.simpleanki.core.data.local.dao.DeckDao
import nart.simpleanki.core.data.local.dao.FolderDao
import nart.simpleanki.core.data.local.dao.ReviewLogDao
import nart.simpleanki.core.data.local.dao.StreakStateDao
import nart.simpleanki.core.data.local.toDomain
import nart.simpleanki.core.data.local.toEntity
import nart.simpleanki.core.data.media.MediaManager
Expand All @@ -27,6 +29,7 @@ class SyncManager(
private val deckDao: DeckDao,
private val cardDao: CardDao,
private val reviewLogDao: ReviewLogDao,
private val streakStateDao: StreakStateDao,
private val remote: RemoteSyncSource,
private val media: MediaManager,
) {
Expand Down Expand Up @@ -72,6 +75,10 @@ class SyncManager(
remote.pushReviewLogs(uid, rows.map { ReviewLogDto.fromDomain(it.toDomain()) })
rows.forEach { reviewLogDao.clearDirty(it.id) }
}
streakStateDao.getDirty()?.let { row ->
remote.pushStreakState(uid, StreakStateDto.fromEntity(row))
streakStateDao.clearDirty(row.lastModified)
}
}

private suspend fun pull(uid: String) {
Expand Down Expand Up @@ -108,6 +115,11 @@ class SyncManager(
.map { it.toDomain().toEntity(dirty = false) }
.takeIf { it.isNotEmpty() }
?.let { reviewLogDao.insertAll(it) }
remote.fetchStreakState(uid)?.let { dto ->
if (shouldApplyRemote(streakStateDao.get()?.lastModified, dto.lastModifiedMillis())) {
streakStateDao.upsert(dto.toEntity(dirty = false))
}
}
}

companion object {
Expand Down
Loading
Loading