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