feat(domain): calculadora de ciclos de sono e modos de cálculo com testes

This commit is contained in:
Murilo Scolari 2026-10-23 20:59:59 -03:00
parent 3980ddbf04
commit bd1796228b
3 changed files with 303 additions and 1 deletions

View file

@ -0,0 +1,160 @@
package me.muriloscolari.ciclo.domain
import java.time.Duration
import java.time.LocalDateTime
/**
* Configurações de ciclos de sono.
*/
data class SleepSettings(
val cycleMinutes: Int = 90,
val fallAsleepMinutes: Int = 15,
val minCycles: Int = 3,
val maxCycles: Int = 6,
val recommendedCycles: Int = 5,
val maxSleepMinutes: Int = 600 // 10 horas
)
enum class SleepMode {
NOW,
BEDTIME,
WAKE_AT
}
/**
* Opção individual de horário calculada.
*/
data class SleepOption(
val cycles: Int,
val bedTime: LocalDateTime,
val wakeTime: LocalDateTime,
val sleepDuration: Duration,
val isRecommended: Boolean,
val isAvailable: Boolean = true
)
/**
* Resultado completo do modo "Acordar às X".
*/
data class WakeAtResult(
val wakeTime: LocalDateTime,
val options: List<SleepOption>,
val hasAnyAvailable: Boolean
)
object SleepCycleCalculator {
/**
* Calcula os horários de acordar partindo de uma hora de deitar [bedTime].
* Utilizado para os modos "Dormir agora" e "Vou dormir às X".
*/
fun wakeTimesFor(bedTime: LocalDateTime, settings: SleepSettings): List<SleepOption> {
val validCycles = (settings.minCycles..settings.maxCycles)
.filter { it * settings.cycleMinutes <= settings.maxSleepMinutes }
if (validCycles.isEmpty()) return emptyList()
val targetRecommended = if (settings.recommendedCycles in validCycles) {
settings.recommendedCycles
} else {
validCycles.maxOrNull() ?: settings.recommendedCycles
}
return validCycles.map { n ->
val sleepDuration = Duration.ofMinutes((n * settings.cycleMinutes).toLong())
val wakeTime = bedTime
.plusMinutes(settings.fallAsleepMinutes.toLong())
.plus(sleepDuration)
.withSecond(0)
.withNano(0)
SleepOption(
cycles = n,
bedTime = bedTime.withSecond(0).withNano(0),
wakeTime = wakeTime,
sleepDuration = sleepDuration,
isRecommended = (n == targetRecommended),
isAvailable = true
)
}
}
/**
* Calcula os horários para deitar partindo de uma hora de despertar [wakeTime].
* [now] é o momento atual para aferir se o horário de ir para a cama já passou.
*/
fun bedTimesFor(
wakeTime: LocalDateTime,
now: LocalDateTime,
settings: SleepSettings
): WakeAtResult {
val standardCycles = (settings.minCycles..settings.maxCycles)
.filter { it * settings.cycleMinutes <= settings.maxSleepMinutes }
val cleanWakeTime = wakeTime.withSecond(0).withNano(0)
val cleanNow = now.withSecond(0).withNano(0)
fun createOption(n: Int, isRec: Boolean): SleepOption {
val sleepDuration = Duration.ofMinutes((n * settings.cycleMinutes).toLong())
val totalMinutes = (n * settings.cycleMinutes) + settings.fallAsleepMinutes
val bedTime = cleanWakeTime.minusMinutes(totalMinutes.toLong())
val isAvailable = bedTime.isAfter(cleanNow)
return SleepOption(
cycles = n,
bedTime = bedTime,
wakeTime = cleanWakeTime,
sleepDuration = sleepDuration,
isRecommended = isRec,
isAvailable = isAvailable
)
}
val standardOptions = standardCycles.map { n -> createOption(n, false) }
val availableStandard = standardOptions.filter { it.isAvailable }
return if (availableStandard.isNotEmpty()) {
// Entre as disponíveis, a recomendada é a com maior número de ciclos até recommendedCycles
val recommendedCandidate = availableStandard
.filter { it.cycles <= settings.recommendedCycles }
.maxByOrNull { it.cycles } ?: availableStandard.maxByOrNull { it.cycles }
val finalOptions = standardOptions.map { opt ->
opt.copy(isRecommended = (opt.cycles == recommendedCandidate?.cycles))
}
WakeAtResult(
wakeTime = cleanWakeTime,
options = finalOptions,
hasAnyAvailable = true
)
} else {
// Nenhuma opção padrão está no futuro. Tenta ciclos menores (2 e 1).
val fallbackCycles = (2 downTo 1).filter { n ->
val totalMinutes = (n * settings.cycleMinutes) + settings.fallAsleepMinutes
val bedTime = cleanWakeTime.minusMinutes(totalMinutes.toLong())
bedTime.isAfter(cleanNow)
}
if (fallbackCycles.isNotEmpty()) {
val fallbackOptions = fallbackCycles.sorted().map { n -> createOption(n, false) }
val highestFallback = fallbackOptions.maxByOrNull { it.cycles }
val finalFallback = fallbackOptions.map { opt ->
opt.copy(isRecommended = (opt.cycles == highestFallback?.cycles))
}
WakeAtResult(
wakeTime = cleanWakeTime,
options = finalFallback,
hasAnyAvailable = true
)
} else {
// Nem 1 ciclo é viável (já é muito perto da hora de acordar)
// Retorna as opções padrão marcadas como indisponíveis para fins de visualização informativa
WakeAtResult(
wakeTime = cleanWakeTime,
options = standardOptions,
hasAnyAvailable = false
)
}
}
}
}

View file

@ -0,0 +1,143 @@
package me.muriloscolari.ciclo.domain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDateTime
class SleepCycleCalculatorTest {
private val defaultSettings = SleepSettings(
cycleMinutes = 90,
fallAsleepMinutes = 15,
minCycles = 3,
maxCycles = 6,
recommendedCycles = 5,
maxSleepMinutes = 600
)
@Test
fun wakeTimesFor_standardInputs_calculatesCorrectTimes() {
// Deitar às 23:00 de hoje
val bedTime = LocalDateTime.of(2026, 10, 23, 23, 0)
val options = SleepCycleCalculator.wakeTimesFor(bedTime, defaultSettings)
assertEquals(4, options.size) // 3, 4, 5, 6 ciclos
// 3 ciclos: 3 * 90m = 270m + 15m = 285m (4h45) -> 03:45 do dia seguinte
val opt3 = options.first { it.cycles == 3 }
assertEquals(LocalDateTime.of(2026, 10, 24, 3, 45), opt3.wakeTime)
assertFalse(opt3.isRecommended)
// 4 ciclos: 4 * 90m = 360m + 15m = 375m (6h15) -> 05:15 do dia seguinte
val opt4 = options.first { it.cycles == 4 }
assertEquals(LocalDateTime.of(2026, 10, 24, 5, 15), opt4.wakeTime)
assertFalse(opt4.isRecommended)
// 5 ciclos: 5 * 90m = 450m + 15m = 465m (7h45) -> 06:45 do dia seguinte
val opt5 = options.first { it.cycles == 5 }
assertEquals(LocalDateTime.of(2026, 10, 24, 6, 45), opt5.wakeTime)
assertTrue("5 ciclos deve ser recomendado por padrão", opt5.isRecommended)
// 6 ciclos: 6 * 90m = 540m + 15m = 555m (9h15) -> 08:15 do dia seguinte
val opt6 = options.first { it.cycles == 6 }
assertEquals(LocalDateTime.of(2026, 10, 24, 8, 15), opt6.wakeTime)
assertFalse(opt6.isRecommended)
}
@Test
fun wakeTimesFor_with120MinuteCycles_respectsMaxSleepCap() {
val settings120 = defaultSettings.copy(cycleMinutes = 120, maxSleepMinutes = 600)
val bedTime = LocalDateTime.of(2026, 10, 23, 22, 0)
val options = SleepCycleCalculator.wakeTimesFor(bedTime, settings120)
// Ciclos avaliados:
// 3 ciclos = 360m (6h) <= 600m OK
// 4 ciclos = 480m (8h) <= 600m OK
// 5 ciclos = 600m (10h) <= 600m OK
// 6 ciclos = 720m (12h) > 600m -> FILTRADO!
assertEquals(3, options.size)
assertTrue(options.none { it.cycles == 6 })
assertTrue(options.any { it.cycles == 5 && it.isRecommended })
}
@Test
fun bedTimesFor_earlyEvening_recommends5Cycles() {
// Deseja acordar amanhã às 07:00. Agora são 20:00.
val now = LocalDateTime.of(2026, 10, 23, 20, 0)
val wakeTime = LocalDateTime.of(2026, 10, 24, 7, 0)
val result = SleepCycleCalculator.bedTimesFor(wakeTime, now, defaultSettings)
assertTrue(result.hasAnyAvailable)
assertEquals(4, result.options.size)
// 5 ciclos: 07:00 - 7h45 = 23:15
val opt5 = result.options.first { it.cycles == 5 }
assertEquals(LocalDateTime.of(2026, 10, 23, 23, 15), opt5.bedTime)
assertTrue(opt5.isAvailable)
assertTrue(opt5.isRecommended)
}
@Test
fun bedTimesFor_lateNight_recommendsHighestViableCycle() {
// Deseja acordar às 07:00. Agora já são 01:00 da madrugada.
// Horários calculados para deitar:
// 6 ciclos: 21:45 (já passou -> indisponível)
// 5 ciclos: 23:15 (já passou -> indisponível)
// 4 ciclos: 00:45 (já passou -> indisponível)
// 3 ciclos: 02:15 (no futuro -> disponível!)
val now = LocalDateTime.of(2026, 10, 24, 1, 0)
val wakeTime = LocalDateTime.of(2026, 10, 24, 7, 0)
val result = SleepCycleCalculator.bedTimesFor(wakeTime, now, defaultSettings)
assertTrue(result.hasAnyAvailable)
val opt6 = result.options.first { it.cycles == 6 }
assertFalse(opt6.isAvailable)
val opt5 = result.options.first { it.cycles == 5 }
assertFalse(opt5.isAvailable)
val opt4 = result.options.first { it.cycles == 4 }
assertFalse(opt4.isAvailable)
val opt3 = result.options.first { it.cycles == 3 }
assertTrue(opt3.isAvailable)
assertTrue("Como é o único viável, 3 ciclos deve ser recomendado", opt3.isRecommended)
}
@Test
fun bedTimesFor_veryLateNight_fallsBackTo1Or2Cycles() {
// Deseja acordar às 07:00. Agora são 04:30 da madrugada.
// 3 ciclos (02:15) já passou.
// 2 ciclos: 07:00 - (180+15)m = 03:45 (já passou).
// 1 ciclo: 07:00 - (90+15)m = 05:15 (viável!).
val now = LocalDateTime.of(2026, 10, 24, 4, 30)
val wakeTime = LocalDateTime.of(2026, 10, 24, 7, 0)
val result = SleepCycleCalculator.bedTimesFor(wakeTime, now, defaultSettings)
assertTrue(result.hasAnyAvailable)
assertEquals(1, result.options.size)
val opt1 = result.options.first()
assertEquals(1, opt1.cycles)
assertEquals(LocalDateTime.of(2026, 10, 24, 5, 15), opt1.bedTime)
assertTrue(opt1.isAvailable)
assertTrue(opt1.isRecommended)
}
@Test
fun bedTimesFor_tooCloseToWakeTime_returnsNoAvailableOptions() {
// Deseja acordar às 07:00. Agora são 06:15 da manhã.
// Nem 1 ciclo de 90m + 15m é viável.
val now = LocalDateTime.of(2026, 10, 24, 6, 15)
val wakeTime = LocalDateTime.of(2026, 10, 24, 7, 0)
val result = SleepCycleCalculator.bedTimesFor(wakeTime, now, defaultSettings)
assertFalse("Não deve haver opções disponíveis", result.hasAnyAvailable)
assertTrue(result.options.all { !it.isAvailable })
}
}

View file

@ -1,5 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.java.home=/usr/lib/jvm/java-21-openjdk
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official