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 package mx.paputec.rachita.di
import android.content.Context import android.content.Context
import androidx.room.Room
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton 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.data.preferences.ProfileRepositoryImpl
import mx.paputec.rachita.domain.repository.HabitRepository
import mx.paputec.rachita.domain.repository.ProfileRepository import mx.paputec.rachita.domain.repository.ProfileRepository
import mx.paputec.rachita.platform.battery.BatteryStatusMonitor
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -18,4 +24,21 @@ object DataModule {
@Singleton @Singleton
fun provideProfileRepository(@ApplicationContext context: Context): ProfileRepository = fun provideProfileRepository(@ApplicationContext context: Context): ProfileRepository =
ProfileRepositoryImpl(context) 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))
}
}