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:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user