feat(habits): capa de datos Room + regla de racha v1

- HabitEntity/HabitLogEntity (1 log por hábito por día, fecha ISO)
- Seed de los 3 hábitos del PRD DA-02 en primer arranque
- StreakCalculator puro: día asegurado = todos los hábitos hechos;
  racha = días consecutivos terminando hoy (o ayer si hoy va en progreso);
  fallar un día completo reinicia a 0 (DA-11 v1, sin congelador)
- 6 tests unitarios de racha (incluye reversibilidad del toggle)
This commit is contained in:
Johann
2026-07-03 22:41:43 -06:00
parent a947c174ee
commit 28f2359dda
9 changed files with 285 additions and 0 deletions
@@ -0,0 +1,56 @@
package mx.paputec.rachita.data
import java.time.LocalDate
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import mx.paputec.rachita.data.local.HabitDao
import mx.paputec.rachita.data.local.HabitEntity
import mx.paputec.rachita.data.local.HabitLogEntity
import mx.paputec.rachita.domain.model.Habit
import mx.paputec.rachita.domain.model.HabitIcon
import mx.paputec.rachita.domain.model.HabitLog
import mx.paputec.rachita.domain.model.HabitPalette
import mx.paputec.rachita.domain.repository.HabitRepository
class HabitRepositoryImpl(
private val dao: HabitDao,
) : HabitRepository {
override val activeHabits: Flow<List<Habit>> =
dao.observeActiveHabits().map { entities -> entities.map { it.toDomain() } }
override val doneLogs: Flow<List<HabitLog>> =
dao.observeDoneLogs().map { logs ->
logs.map { HabitLog(habitId = it.habitId, date = LocalDate.parse(it.date)) }
}
override suspend fun toggleHabit(habitId: String, date: LocalDate) {
val iso = date.toString()
val current = dao.getLog(habitId, iso)
dao.upsertLog(
HabitLogEntity(habitId = habitId, date = iso, done = !(current?.done ?: false)),
)
}
override suspend fun seedDefaultHabitsIfEmpty() {
if (dao.countHabits() > 0) return
// PRD DA-02 example habits — copy exact from prototype.
val now = System.currentTimeMillis()
dao.insertHabits(
listOf(
HabitEntity("banarse", "Bañarse", "Antes de dormir", HabitIcon.Drop.name, HabitPalette.Mint.name, 0, true, now),
HabitEntity("dormir", "Dormir temprano", "A las 9:00 pm", HabitIcon.Moon.name, HabitPalette.Lilac.name, 1, true, now),
HabitEntity("escuela", "Ir a la escuela", "Sin faltar nunca", HabitIcon.School.name, HabitPalette.Coral.name, 2, true, now),
),
)
}
}
private fun HabitEntity.toDomain() = Habit(
id = id,
name = name,
subtitle = subtitle,
icon = HabitIcon.entries.firstOrNull { it.name == icon } ?: HabitIcon.Drop,
palette = HabitPalette.entries.firstOrNull { it.name == palette } ?: HabitPalette.Mint,
sortOrder = sortOrder,
)
@@ -0,0 +1,30 @@
package mx.paputec.rachita.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface HabitDao {
@Query("SELECT * FROM habits WHERE active = 1 ORDER BY sortOrder")
fun observeActiveHabits(): Flow<List<HabitEntity>>
@Query("SELECT * FROM habit_logs WHERE done = 1")
fun observeDoneLogs(): Flow<List<HabitLogEntity>>
@Query("SELECT * FROM habit_logs WHERE habitId = :habitId AND date = :date")
suspend fun getLog(habitId: String, date: String): HabitLogEntity?
@Upsert
suspend fun upsertLog(log: HabitLogEntity)
@Query("SELECT COUNT(*) FROM habits")
suspend fun countHabits(): Int
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertHabits(habits: List<HabitEntity>)
}
@@ -0,0 +1,25 @@
package mx.paputec.rachita.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "habits")
data class HabitEntity(
@PrimaryKey val id: String,
val name: String,
val subtitle: String,
val icon: String,
val palette: String,
val sortOrder: Int,
val active: Boolean,
val createdAtEpochMs: Long,
)
// One row per habit per day (CLAUDE.md §7). Date stored as ISO-8601 (yyyy-MM-dd)
// so range queries stay lexicographically correct without converters.
@Entity(tableName = "habit_logs", primaryKeys = ["habitId", "date"])
data class HabitLogEntity(
val habitId: String,
val date: String,
val done: Boolean,
)
@@ -0,0 +1,13 @@
package mx.paputec.rachita.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [HabitEntity::class, HabitLogEntity::class],
version = 1,
exportSchema = false,
)
abstract class RachitaDatabase : RoomDatabase() {
abstract fun habitDao(): HabitDao
}
@@ -1,14 +1,20 @@
package mx.paputec.rachita.di
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import mx.paputec.rachita.data.HabitRepositoryImpl
import mx.paputec.rachita.data.local.HabitDao
import mx.paputec.rachita.data.local.RachitaDatabase
import mx.paputec.rachita.data.preferences.ProfileRepositoryImpl
import mx.paputec.rachita.domain.repository.HabitRepository
import mx.paputec.rachita.domain.repository.ProfileRepository
import mx.paputec.rachita.platform.battery.BatteryStatusMonitor
@Module
@InstallIn(SingletonComponent::class)
@@ -18,4 +24,21 @@ object DataModule {
@Singleton
fun provideProfileRepository(@ApplicationContext context: Context): ProfileRepository =
ProfileRepositoryImpl(context)
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): RachitaDatabase =
Room.databaseBuilder(context, RachitaDatabase::class.java, "rachita.db").build()
@Provides
fun provideHabitDao(db: RachitaDatabase): HabitDao = db.habitDao()
@Provides
@Singleton
fun provideHabitRepository(dao: HabitDao): HabitRepository = HabitRepositoryImpl(dao)
@Provides
@Singleton
fun provideBatteryStatusMonitor(@ApplicationContext context: Context): BatteryStatusMonitor =
BatteryStatusMonitor(context)
}
@@ -0,0 +1,35 @@
package mx.paputec.rachita.domain
import java.time.LocalDate
import mx.paputec.rachita.domain.model.HabitLog
/**
* Streak rules from PRD DA-11 (v1, no freezer):
* - A date is "secured" when every active habit has a done log on it.
* - The streak is the consecutive run of secured dates ending today — or ending
* yesterday while today is still in progress. Missing a full day resets to 0.
*
* Everything is computed from logs, never stored, so toggling a habit off
* (DA-03 reversibility) naturally rolls the streak back with no drift.
*/
object StreakCalculator {
fun securedDates(doneLogs: List<HabitLog>, activeHabitIds: Set<String>): Set<LocalDate> {
if (activeHabitIds.isEmpty()) return emptySet()
return doneLogs
.filter { it.habitId in activeHabitIds }
.groupBy({ it.date }, { it.habitId })
.filterValues { it.toSet().containsAll(activeHabitIds) }
.keys
}
fun currentStreak(secured: Set<LocalDate>, today: LocalDate): Int {
var day = if (today in secured) today else today.minusDays(1)
var count = 0
while (day in secured) {
count++
day = day.minusDays(1)
}
return count
}
}
@@ -0,0 +1,28 @@
package mx.paputec.rachita.domain.model
import java.time.LocalDate
// Icon and palette are semantic keys; the UI layer maps them to vectors/colors
// so the domain stays Android-free (CLAUDE.md §6.2 spirit applied to the client).
enum class HabitIcon { Drop, Moon, School }
enum class HabitPalette { Mint, Lilac, Coral }
data class Habit(
val id: String,
val name: String,
val subtitle: String,
val icon: HabitIcon,
val palette: HabitPalette,
val sortOrder: Int,
)
data class HabitLog(
val habitId: String,
val date: LocalDate,
)
data class BatteryStatus(
val percent: Int,
val isCharging: Boolean,
)
@@ -0,0 +1,20 @@
package mx.paputec.rachita.domain.repository
import java.time.LocalDate
import kotlinx.coroutines.flow.Flow
import mx.paputec.rachita.domain.model.Habit
import mx.paputec.rachita.domain.model.HabitLog
interface HabitRepository {
/** Active habits ordered for display. */
val activeHabits: Flow<List<Habit>>
/** Every log marked done, across all dates (small volume: habits × days). */
val doneLogs: Flow<List<HabitLog>>
/** Flips today's done state for the habit (DA-03: toggle is reversible). */
suspend fun toggleHabit(habitId: String, date: LocalDate)
/** Inserts the three PRD §5.2 example habits on first run (DA-02). */
suspend fun seedDefaultHabitsIfEmpty()
}
@@ -0,0 +1,55 @@
package mx.paputec.rachita.domain
import java.time.LocalDate
import mx.paputec.rachita.domain.model.HabitLog
import org.junit.Assert.assertEquals
import org.junit.Test
class StreakCalculatorTest {
private val habits = setOf("banarse", "dormir", "escuela")
private val today: LocalDate = LocalDate.of(2026, 7, 3)
private fun securedDay(date: LocalDate): List<HabitLog> = habits.map { HabitLog(it, date) }
@Test
fun `day counts as secured only when every active habit is done`() {
val logs = securedDay(today) + listOf(HabitLog("banarse", today.minusDays(1)))
val secured = StreakCalculator.securedDates(logs, habits)
assertEquals(setOf(today), secured)
}
@Test
fun `streak counts consecutive secured days including today`() {
val logs = securedDay(today) + securedDay(today.minusDays(1)) + securedDay(today.minusDays(2))
val secured = StreakCalculator.securedDates(logs, habits)
assertEquals(3, StreakCalculator.currentStreak(secured, today))
}
@Test
fun `streak survives while today is still in progress`() {
val logs = securedDay(today.minusDays(1)) + securedDay(today.minusDays(2))
val secured = StreakCalculator.securedDates(logs, habits)
assertEquals(2, StreakCalculator.currentStreak(secured, today))
}
@Test
fun `a missed full day resets the streak to zero`() {
val logs = securedDay(today.minusDays(2)) + securedDay(today.minusDays(3))
val secured = StreakCalculator.securedDates(logs, habits)
assertEquals(0, StreakCalculator.currentStreak(secured, today))
}
@Test
fun `empty history yields zero streak`() {
assertEquals(0, StreakCalculator.currentStreak(emptySet(), today))
}
@Test
fun `unchecking a habit today rolls the streak back`() {
val fullYesterday = securedDay(today.minusDays(1))
val partialToday = listOf(HabitLog("banarse", today), HabitLog("dormir", today))
val secured = StreakCalculator.securedDates(fullYesterday + partialToday, habits)
assertEquals(1, StreakCalculator.currentStreak(secured, today))
}
}