mirror of
https://github.com/iSoron/uhabits.git
synced 2025-12-06 01:08:50 -06:00
Merge pull request #1101 from KristianTashkov/kris/implement_at_most
Implement numerical habits with AT_MOST target type
This commit is contained in:
@@ -31,5 +31,6 @@ data class CreateHabitCommand(
|
||||
val habit = modelFactory.buildHabit()
|
||||
habit.copyFrom(model)
|
||||
habitList.add(habit)
|
||||
habit.recompute()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,14 +88,16 @@ data class Habit(
|
||||
isNumerical = isNumerical,
|
||||
)
|
||||
|
||||
val to = DateUtils.getTodayWithOffset().plus(30)
|
||||
val today = DateUtils.getTodayWithOffset()
|
||||
val to = today.plus(30)
|
||||
val entries = computedEntries.getKnown()
|
||||
var from = entries.lastOrNull()?.timestamp ?: to
|
||||
var from = entries.lastOrNull()?.timestamp ?: today
|
||||
if (from.isNewerThan(to)) from = to
|
||||
|
||||
scores.recompute(
|
||||
frequency = frequency,
|
||||
isNumerical = isNumerical,
|
||||
numericalHabitType = targetType,
|
||||
targetValue = targetValue,
|
||||
computedEntries = computedEntries,
|
||||
from = from,
|
||||
|
||||
@@ -68,19 +68,19 @@ class ScoreList {
|
||||
fun recompute(
|
||||
frequency: Frequency,
|
||||
isNumerical: Boolean,
|
||||
numericalHabitType: NumericalHabitType,
|
||||
targetValue: Double,
|
||||
computedEntries: EntryList,
|
||||
from: Timestamp,
|
||||
to: Timestamp,
|
||||
) {
|
||||
map.clear()
|
||||
if (computedEntries.getKnown().isEmpty()) return
|
||||
if (from.isNewerThan(to)) return
|
||||
var rollingSum = 0.0
|
||||
var numerator = frequency.numerator
|
||||
var denominator = frequency.denominator
|
||||
val freq = frequency.toDouble()
|
||||
val values = computedEntries.getByInterval(from, to).map { it.value }.toIntArray()
|
||||
val isAtMost = numericalHabitType == NumericalHabitType.AT_MOST
|
||||
|
||||
// For non-daily boolean habits, we double the numerator and the denominator to smooth
|
||||
// out irregular repetition schedules (for example, weekly habits performed on different
|
||||
@@ -90,19 +90,29 @@ class ScoreList {
|
||||
denominator *= 2
|
||||
}
|
||||
|
||||
var previousValue = 0.0
|
||||
var previousValue = if (isNumerical && isAtMost) 1.0 else 0.0
|
||||
for (i in values.indices) {
|
||||
val offset = values.size - i - 1
|
||||
if (isNumerical) {
|
||||
rollingSum += max(0, values[offset])
|
||||
if (offset + denominator < values.size) {
|
||||
rollingSum -= values[offset + denominator]
|
||||
rollingSum -= max(0, values[offset + denominator])
|
||||
}
|
||||
val percentageCompleted = if (targetValue > 0) {
|
||||
min(1.0, rollingSum / 1000 / targetValue)
|
||||
|
||||
val normalizedRollingSum = rollingSum / 1000
|
||||
val percentageCompleted = if (!isAtMost) {
|
||||
if (targetValue > 0)
|
||||
min(1.0, normalizedRollingSum / targetValue)
|
||||
else
|
||||
1.0
|
||||
} else {
|
||||
1.0
|
||||
if (targetValue > 0) {
|
||||
(1 - ((normalizedRollingSum - targetValue) / targetValue)).coerceIn(0.0, 1.0)
|
||||
} else {
|
||||
if (normalizedRollingSum > 0) 0.0 else 1.0
|
||||
}
|
||||
}
|
||||
|
||||
previousValue = compute(freq, previousValue, percentageCompleted)
|
||||
} else {
|
||||
if (values[offset] == Entry.YES_MANUAL) {
|
||||
|
||||
@@ -50,6 +50,19 @@ class HabitFixtures(private val modelFactory: ModelFactory, private val habitLis
|
||||
return habit
|
||||
}
|
||||
|
||||
fun createEmptyNumericalHabit(targetType: NumericalHabitType): Habit {
|
||||
val habit = modelFactory.buildHabit()
|
||||
habit.type = HabitType.NUMERICAL
|
||||
habit.name = "Run"
|
||||
habit.question = "How many miles did you run today?"
|
||||
habit.unit = "miles"
|
||||
habit.targetType = targetType
|
||||
habit.targetValue = 2.0
|
||||
habit.color = PaletteColor(1)
|
||||
saveIfSQLite(habit)
|
||||
return habit
|
||||
}
|
||||
|
||||
fun createLongHabit(): Habit {
|
||||
val habit = createEmptyHabit()
|
||||
habit.frequency = Frequency(3, 7)
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.isoron.uhabits.core.models.Entry.Companion.YES_AUTO
|
||||
import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL
|
||||
import org.isoron.uhabits.core.models.Habit
|
||||
import org.isoron.uhabits.core.models.HabitList
|
||||
import org.isoron.uhabits.core.models.NumericalHabitType
|
||||
import org.isoron.uhabits.core.models.PaletteColor
|
||||
import org.isoron.uhabits.core.models.Timestamp
|
||||
import org.isoron.uhabits.core.preferences.Preferences
|
||||
@@ -44,6 +45,7 @@ data class HistoryCardState(
|
||||
val color: PaletteColor,
|
||||
val firstWeekday: DayOfWeek,
|
||||
val series: List<HistoryChart.Square>,
|
||||
val defaultSquare: HistoryChart.Square,
|
||||
val theme: Theme,
|
||||
val today: LocalDate,
|
||||
)
|
||||
@@ -105,12 +107,19 @@ class HistoryCardPresenter(
|
||||
val oldest = habit.computedEntries.getKnown().lastOrNull()?.timestamp ?: today
|
||||
val entries = habit.computedEntries.getByInterval(oldest, today)
|
||||
val series = if (habit.isNumerical) {
|
||||
entries.map {
|
||||
Entry(it.timestamp, max(0, it.value))
|
||||
}.map {
|
||||
when (it.value) {
|
||||
0 -> HistoryChart.Square.OFF
|
||||
else -> HistoryChart.Square.ON
|
||||
if (habit.targetType == NumericalHabitType.AT_LEAST) {
|
||||
entries.map {
|
||||
when (max(0, it.value)) {
|
||||
0 -> HistoryChart.Square.OFF
|
||||
else -> HistoryChart.Square.ON
|
||||
}
|
||||
}
|
||||
} else {
|
||||
entries.map {
|
||||
when {
|
||||
max(0.0, it.value / 1000.0) <= habit.targetValue -> HistoryChart.Square.ON
|
||||
else -> HistoryChart.Square.OFF
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -123,6 +132,10 @@ class HistoryCardPresenter(
|
||||
}
|
||||
}
|
||||
}
|
||||
val defaultSquare = if (habit.isNumerical && habit.targetType == NumericalHabitType.AT_MOST)
|
||||
HistoryChart.Square.ON
|
||||
else
|
||||
HistoryChart.Square.OFF
|
||||
|
||||
return HistoryCardState(
|
||||
color = habit.color,
|
||||
@@ -130,6 +143,7 @@ class HistoryCardPresenter(
|
||||
today = today.toLocalDate(),
|
||||
theme = theme,
|
||||
series = series,
|
||||
defaultSquare = defaultSquare
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ package org.isoron.uhabits.core.ui.screens.habits.show.views
|
||||
|
||||
import org.isoron.uhabits.core.models.Frequency
|
||||
import org.isoron.uhabits.core.models.Habit
|
||||
import org.isoron.uhabits.core.models.NumericalHabitType
|
||||
import org.isoron.uhabits.core.models.PaletteColor
|
||||
import org.isoron.uhabits.core.models.Reminder
|
||||
import org.isoron.uhabits.core.ui.views.Theme
|
||||
@@ -31,8 +32,9 @@ data class SubtitleCardState(
|
||||
val isNumerical: Boolean,
|
||||
val question: String,
|
||||
val reminder: Reminder?,
|
||||
val targetValue: Double,
|
||||
val unit: String,
|
||||
val targetValue: Double = 0.0,
|
||||
val targetType: NumericalHabitType = NumericalHabitType.AT_LEAST,
|
||||
val unit: String = "",
|
||||
val theme: Theme,
|
||||
)
|
||||
|
||||
@@ -48,6 +50,7 @@ class SubtitleCardPresenter {
|
||||
question = habit.question,
|
||||
reminder = habit.reminder,
|
||||
targetValue = habit.targetValue,
|
||||
targetType = habit.targetType,
|
||||
unit = habit.unit,
|
||||
theme = theme,
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ class HistoryChart(
|
||||
var firstWeekday: DayOfWeek,
|
||||
var paletteColor: PaletteColor,
|
||||
var series: List<Square>,
|
||||
var defaultSquare: Square,
|
||||
var theme: Theme,
|
||||
var today: LocalDate,
|
||||
var onDateClickedListener: OnDateClickedListener = OnDateClickedListener { },
|
||||
@@ -189,7 +190,7 @@ class HistoryChart(
|
||||
offset: Int,
|
||||
) {
|
||||
|
||||
val value = if (offset >= series.size) Square.OFF else series[offset]
|
||||
val value = if (offset >= series.size) defaultSquare else series[offset]
|
||||
val squareColor: Color
|
||||
val color = theme.color(paletteColor.paletteIndex)
|
||||
squareColor = when (value) {
|
||||
|
||||
@@ -28,14 +28,36 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.ArrayList
|
||||
|
||||
class ScoreListTest : BaseUnitTest() {
|
||||
private lateinit var habit: Habit
|
||||
private lateinit var today: Timestamp
|
||||
open class BaseScoreListTest : BaseUnitTest() {
|
||||
protected lateinit var habit: Habit
|
||||
protected lateinit var today: Timestamp
|
||||
|
||||
@Before
|
||||
@Throws(Exception::class)
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
today = getToday()
|
||||
}
|
||||
|
||||
protected fun checkScoreValues(expectedValues: DoubleArray) {
|
||||
var current = today
|
||||
val scores = habit.scores
|
||||
for (expectedValue in expectedValues) {
|
||||
assertThat(scores[current].value, IsCloseTo.closeTo(expectedValue, E))
|
||||
current = current.minus(1)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val E = 1e-6
|
||||
}
|
||||
}
|
||||
|
||||
class YesNoScoreListTest : BaseScoreListTest() {
|
||||
@Before
|
||||
@Throws(Exception::class)
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
habit = fixtures.createEmptyHabit()
|
||||
}
|
||||
|
||||
@@ -122,14 +144,6 @@ class ScoreListTest : BaseUnitTest() {
|
||||
checkScoreValues(expectedValues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_withZeroTarget() {
|
||||
habit = fixtures.createNumericalHabit()
|
||||
habit.targetValue = 0.0
|
||||
habit.recompute()
|
||||
assertTrue(habit.scores[today].value.isFinite())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_imperfectNonDaily() {
|
||||
// If the habit should be performed 3 times per week and the user misses 1 repetition
|
||||
@@ -255,17 +269,204 @@ class ScoreListTest : BaseUnitTest() {
|
||||
val entries = habit.originalEntries
|
||||
entries.add(Entry(today.minus(day), Entry.SKIP))
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkScoreValues(expectedValues: DoubleArray) {
|
||||
var current = today
|
||||
val scores = habit.scores
|
||||
for (expectedValue in expectedValues) {
|
||||
assertThat(scores[current].value, IsCloseTo.closeTo(expectedValue, E))
|
||||
current = current.minus(1)
|
||||
}
|
||||
open class NumericalScoreListTest : BaseScoreListTest() {
|
||||
protected fun addEntry(day: Int, value: Int) {
|
||||
val entries = habit.originalEntries
|
||||
entries.add(Entry(today.minus(day), value))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val E = 1e-6
|
||||
protected fun addEntries(from: Int, to: Int, value: Int) {
|
||||
val entries = habit.originalEntries
|
||||
for (i in from until to) entries.add(Entry(today.minus(i), value))
|
||||
habit.recompute()
|
||||
}
|
||||
}
|
||||
|
||||
class NumericalAtLeastScoreListTest : NumericalScoreListTest() {
|
||||
@Before
|
||||
@Throws(Exception::class)
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
habit = fixtures.createEmptyNumericalHabit(NumericalHabitType.AT_LEAST)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_withZeroTarget() {
|
||||
habit = fixtures.createNumericalHabit()
|
||||
habit.targetValue = 0.0
|
||||
habit.recompute()
|
||||
assertTrue(habit.scores[today].value.isFinite())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getValue() {
|
||||
addEntries(0, 20, 2000)
|
||||
val expectedValues = doubleArrayOf(
|
||||
0.655747,
|
||||
0.636894,
|
||||
0.617008,
|
||||
0.596033,
|
||||
0.573910,
|
||||
0.550574,
|
||||
0.525961,
|
||||
0.500000,
|
||||
0.472617,
|
||||
0.443734,
|
||||
0.413270,
|
||||
0.381137,
|
||||
0.347244,
|
||||
0.311495,
|
||||
0.273788,
|
||||
0.234017,
|
||||
0.192067,
|
||||
0.147820,
|
||||
0.101149,
|
||||
0.051922,
|
||||
0.000000,
|
||||
0.000000,
|
||||
0.000000
|
||||
)
|
||||
checkScoreValues(expectedValues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_recompute() {
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.0, E))
|
||||
addEntries(0, 2, 2000)
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.101149, E))
|
||||
habit.frequency = Frequency(1, 2)
|
||||
habit.recompute()
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.072631, E))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldAchieveHighScoreInReasonableTime() {
|
||||
// Daily habits should achieve at least 99% in 3 months
|
||||
habit = fixtures.createEmptyNumericalHabit(NumericalHabitType.AT_LEAST)
|
||||
habit.frequency = Frequency.DAILY
|
||||
for (i in 0..89) addEntry(i, 2000)
|
||||
habit.recompute()
|
||||
assertThat(habit.scores[today].value, OrderingComparison.greaterThan(0.99))
|
||||
|
||||
// Weekly habits should achieve at least 99% in 9 months
|
||||
habit = fixtures.createEmptyNumericalHabit(NumericalHabitType.AT_LEAST)
|
||||
habit.frequency = Frequency.WEEKLY
|
||||
for (i in 0..38) addEntry(7 * i, 2000)
|
||||
habit.recompute()
|
||||
assertThat(habit.scores[today].value, OrderingComparison.greaterThan(0.99))
|
||||
|
||||
// Monthly habits should achieve at least 99% in 18 months
|
||||
habit.frequency = Frequency(1, 30)
|
||||
for (i in 0..17) addEntry(30 * i, 2000)
|
||||
habit.recompute()
|
||||
assertThat(habit.scores[today].value, OrderingComparison.greaterThan(0.99))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldAchieveComparableScoreToProgress() {
|
||||
addEntries(0, 500, 1000)
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.5, E))
|
||||
|
||||
addEntries(0, 500, 500)
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.25, E))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overeachievingIsntRelevant() {
|
||||
addEntry(0, 10000000)
|
||||
habit.recompute()
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.051922, E))
|
||||
}
|
||||
}
|
||||
|
||||
class NumericalAtMostScoreListTest : NumericalScoreListTest() {
|
||||
@Before
|
||||
@Throws(Exception::class)
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
habit = fixtures.createEmptyNumericalHabit(NumericalHabitType.AT_MOST)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_withZeroTarget() {
|
||||
habit = fixtures.createNumericalHabit()
|
||||
habit.targetType = NumericalHabitType.AT_MOST
|
||||
habit.targetValue = 0.0
|
||||
habit.recompute()
|
||||
assertTrue(habit.scores[today].value.isFinite())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getValue() {
|
||||
addEntry(20, 1000)
|
||||
addEntries(0, 20, 5000)
|
||||
val expectedValues = doubleArrayOf(
|
||||
0.344253,
|
||||
0.363106,
|
||||
0.382992,
|
||||
0.403967,
|
||||
0.426090,
|
||||
0.449426,
|
||||
0.474039,
|
||||
0.500000,
|
||||
0.527383,
|
||||
0.556266,
|
||||
0.586730,
|
||||
0.618863,
|
||||
0.652756,
|
||||
0.688505,
|
||||
0.726212,
|
||||
0.765983,
|
||||
0.807933,
|
||||
0.852180,
|
||||
0.898851,
|
||||
0.948078,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
)
|
||||
checkScoreValues(expectedValues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_recompute() {
|
||||
habit.recompute()
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(1.0, E))
|
||||
addEntries(0, 2, 5000)
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.898850, E))
|
||||
habit.frequency = Frequency(1, 2)
|
||||
habit.recompute()
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.927369, E))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldAchieveComparableScoreToProgress() {
|
||||
addEntries(0, 500, 3000)
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.5, E))
|
||||
|
||||
addEntries(0, 500, 3500)
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.25, E))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun undereachievingIsntRelevant() {
|
||||
addEntry(1, 10000000)
|
||||
habit.recompute()
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.950773, E))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overeachievingIsntRelevant() {
|
||||
addEntry(0, 5000)
|
||||
|
||||
addEntry(1, 0)
|
||||
habit.recompute()
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.948077, E))
|
||||
|
||||
addEntry(1, 1000)
|
||||
habit.recompute()
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(0.948077, E))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ class HistoryChartTest {
|
||||
dateFormatter = JavaLocalDateFormatter(Locale.US),
|
||||
firstWeekday = SUNDAY,
|
||||
onDateClickedListener = dateClickedListener,
|
||||
defaultSquare = OFF,
|
||||
series = listOf(
|
||||
2, // today
|
||||
2, 1, 2, 1, 2, 1, 2,
|
||||
|
||||
Reference in New Issue
Block a user