mirror of
https://github.com/iSoron/uhabits.git
synced 2025-12-06 09:08:52 -06:00
Issue 1316: Skip measurable habit (#1319)
Co-authored-by: Jakub Kalinowski <kalj@netcompany.com>
This commit is contained in:
@@ -276,6 +276,8 @@ open class EntryList {
|
||||
* For numerical habits, non-positive entry values are converted to zero. For boolean habits, each
|
||||
* YES_MANUAL value is converted to 1000 and all other values are converted to zero.
|
||||
*
|
||||
* SKIP values are converted to zero (if they weren't, each SKIP day would count as 0.003).
|
||||
*
|
||||
* The returned list is sorted by timestamp, with the newest entry coming first and the oldest entry
|
||||
* coming last. If the original list has gaps in it (for example, weeks or months without any
|
||||
* entries), then the list produced by this method will also have gaps.
|
||||
@@ -289,7 +291,10 @@ fun List<Entry>.groupedSum(
|
||||
): List<Entry> {
|
||||
return this.map { (timestamp, value) ->
|
||||
if (isNumerical) {
|
||||
Entry(timestamp, max(0, value))
|
||||
if (value == SKIP)
|
||||
Entry(timestamp, 0)
|
||||
else
|
||||
Entry(timestamp, max(0, value))
|
||||
} else {
|
||||
Entry(timestamp, if (value == YES_MANUAL) 1000 else 0)
|
||||
}
|
||||
@@ -301,6 +306,31 @@ fun List<Entry>.groupedSum(
|
||||
}.entries.map { (timestamp, entries) ->
|
||||
Entry(timestamp, entries.sumOf { it.value })
|
||||
}.sortedBy { (timestamp, _) ->
|
||||
- timestamp.unixTime
|
||||
-timestamp.unixTime
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of days with vaLue SKIP in the given period.
|
||||
*/
|
||||
fun List<Entry>.countSkippedDays(
|
||||
truncateField: DateUtils.TruncateField,
|
||||
firstWeekday: Int = Calendar.SATURDAY
|
||||
): List<Entry> {
|
||||
return this.map { (timestamp, value) ->
|
||||
if (value == SKIP) {
|
||||
Entry(timestamp, 1)
|
||||
} else {
|
||||
Entry(timestamp, 0)
|
||||
}
|
||||
}.groupBy { entry ->
|
||||
entry.timestamp.truncate(
|
||||
truncateField,
|
||||
firstWeekday,
|
||||
)
|
||||
}.entries.map { (timestamp, entries) ->
|
||||
Entry(timestamp, entries.sumOf { it.value })
|
||||
}.sortedBy { (timestamp, _) ->
|
||||
-timestamp.unixTime
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,20 +100,25 @@ class ScoreList {
|
||||
}
|
||||
|
||||
val normalizedRollingSum = rollingSum / 1000
|
||||
val percentageCompleted = if (!isAtMost) {
|
||||
if (targetValue > 0)
|
||||
min(1.0, normalizedRollingSum / targetValue)
|
||||
else
|
||||
1.0
|
||||
} else {
|
||||
if (targetValue > 0) {
|
||||
(1 - ((normalizedRollingSum - targetValue) / targetValue)).coerceIn(0.0, 1.0)
|
||||
if (values[offset] != Entry.SKIP) {
|
||||
val percentageCompleted = if (!isAtMost) {
|
||||
if (targetValue > 0)
|
||||
min(1.0, normalizedRollingSum / targetValue)
|
||||
else
|
||||
1.0
|
||||
} else {
|
||||
if (normalizedRollingSum > 0) 0.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)
|
||||
previousValue = compute(freq, previousValue, percentageCompleted)
|
||||
}
|
||||
} else {
|
||||
if (values[offset] == Entry.YES_MANUAL) {
|
||||
rollingSum += 1.0
|
||||
|
||||
@@ -21,6 +21,7 @@ package org.isoron.uhabits.core.ui.screens.habits.list
|
||||
import org.isoron.platform.time.LocalDate
|
||||
import org.isoron.uhabits.core.commands.CommandRunner
|
||||
import org.isoron.uhabits.core.commands.CreateRepetitionCommand
|
||||
import org.isoron.uhabits.core.models.Frequency
|
||||
import org.isoron.uhabits.core.models.Habit
|
||||
import org.isoron.uhabits.core.models.HabitList
|
||||
import org.isoron.uhabits.core.models.HabitType
|
||||
@@ -58,6 +59,7 @@ open class ListHabitsBehavior @Inject constructor(
|
||||
habit.unit,
|
||||
entry.notes,
|
||||
timestamp.toDialogDateString(),
|
||||
habit.frequency
|
||||
) { newValue: Double, newNotes: String, ->
|
||||
val value = (newValue * 1000).roundToInt()
|
||||
commandRunner.run(CreateRepetitionCommand(habitList, habit, timestamp, value, newNotes))
|
||||
@@ -167,6 +169,7 @@ open class ListHabitsBehavior @Inject constructor(
|
||||
unit: String,
|
||||
notes: String,
|
||||
dateString: String,
|
||||
frequency: Frequency,
|
||||
callback: NumberPickerCallback
|
||||
)
|
||||
fun showCheckmarkDialog(
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.isoron.uhabits.core.models.Entry
|
||||
import org.isoron.uhabits.core.models.Entry.Companion.SKIP
|
||||
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.Frequency
|
||||
import org.isoron.uhabits.core.models.Habit
|
||||
import org.isoron.uhabits.core.models.HabitList
|
||||
import org.isoron.uhabits.core.models.NumericalHabitType.AT_LEAST
|
||||
@@ -123,6 +124,7 @@ class HistoryCardPresenter(
|
||||
habit.unit,
|
||||
entry.notes,
|
||||
timestamp.toDialogDateString(),
|
||||
frequency = habit.frequency
|
||||
) { newValue: Double, newNotes: String ->
|
||||
val thousands = (newValue * 1000).roundToInt()
|
||||
commandRunner.run(
|
||||
@@ -154,6 +156,7 @@ class HistoryCardPresenter(
|
||||
entries.map {
|
||||
when {
|
||||
it.value == Entry.UNKNOWN -> OFF
|
||||
it.value == SKIP -> HATCHED
|
||||
(habit.targetType == AT_MOST) && (it.value / 1000.0 <= habit.targetValue) -> ON
|
||||
(habit.targetType == AT_LEAST) && (it.value / 1000.0 >= habit.targetValue) -> ON
|
||||
else -> GREY
|
||||
@@ -196,8 +199,10 @@ class HistoryCardPresenter(
|
||||
unit: String,
|
||||
notes: String,
|
||||
dateString: String,
|
||||
callback: ListHabitsBehavior.NumberPickerCallback,
|
||||
frequency: Frequency,
|
||||
callback: ListHabitsBehavior.NumberPickerCallback
|
||||
)
|
||||
|
||||
fun showCheckmarkDialog(
|
||||
selectedValue: Int,
|
||||
notes: String,
|
||||
|
||||
@@ -21,6 +21,7 @@ package org.isoron.uhabits.core.ui.screens.habits.show.views
|
||||
|
||||
import org.isoron.uhabits.core.models.Habit
|
||||
import org.isoron.uhabits.core.models.PaletteColor
|
||||
import org.isoron.uhabits.core.models.countSkippedDays
|
||||
import org.isoron.uhabits.core.models.groupedSum
|
||||
import org.isoron.uhabits.core.ui.views.Theme
|
||||
import org.isoron.uhabits.core.utils.DateUtils
|
||||
@@ -51,37 +52,59 @@ class TargetCardPresenter {
|
||||
isNumerical = habit.isNumerical
|
||||
).firstOrNull()?.value ?: 0
|
||||
|
||||
val skippedDayToday = entries.countSkippedDays(
|
||||
truncateField = DateUtils.TruncateField.DAY
|
||||
).firstOrNull()?.value ?: 0
|
||||
|
||||
val valueThisWeek = entries.groupedSum(
|
||||
truncateField = DateUtils.TruncateField.WEEK_NUMBER,
|
||||
firstWeekday = firstWeekday,
|
||||
isNumerical = habit.isNumerical
|
||||
).firstOrNull()?.value ?: 0
|
||||
|
||||
val skippedDaysThisWeek = entries.countSkippedDays(
|
||||
truncateField = DateUtils.TruncateField.WEEK_NUMBER,
|
||||
firstWeekday = firstWeekday
|
||||
).firstOrNull()?.value ?: 0
|
||||
|
||||
val valueThisMonth = entries.groupedSum(
|
||||
truncateField = DateUtils.TruncateField.MONTH,
|
||||
isNumerical = habit.isNumerical
|
||||
).firstOrNull()?.value ?: 0
|
||||
|
||||
val skippedDaysThisMonth = entries.countSkippedDays(
|
||||
truncateField = DateUtils.TruncateField.MONTH,
|
||||
).firstOrNull()?.value ?: 0
|
||||
|
||||
val valueThisQuarter = entries.groupedSum(
|
||||
truncateField = DateUtils.TruncateField.QUARTER,
|
||||
isNumerical = habit.isNumerical
|
||||
).firstOrNull()?.value ?: 0
|
||||
|
||||
val skippedDaysThisQuarter = entries.countSkippedDays(
|
||||
truncateField = DateUtils.TruncateField.QUARTER
|
||||
).firstOrNull()?.value ?: 0
|
||||
|
||||
val valueThisYear = entries.groupedSum(
|
||||
truncateField = DateUtils.TruncateField.YEAR,
|
||||
isNumerical = habit.isNumerical
|
||||
).firstOrNull()?.value ?: 0
|
||||
|
||||
val skippedDaysThisYear = entries.countSkippedDays(
|
||||
truncateField = DateUtils.TruncateField.YEAR
|
||||
).firstOrNull()?.value ?: 0
|
||||
|
||||
val cal = DateUtils.getStartOfTodayCalendarWithOffset()
|
||||
val daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH)
|
||||
val daysInQuarter = 91
|
||||
val daysInYear = cal.getActualMaximum(Calendar.DAY_OF_YEAR)
|
||||
|
||||
val targetToday = habit.targetValue / habit.frequency.denominator
|
||||
val targetThisWeek = targetToday * 7
|
||||
val targetThisMonth = targetToday * daysInMonth
|
||||
val targetThisQuarter = targetToday * daysInQuarter
|
||||
val targetThisYear = targetToday * daysInYear
|
||||
val dailyTarget = habit.targetValue / habit.frequency.denominator
|
||||
val targetToday = dailyTarget * (1 - skippedDayToday)
|
||||
val targetThisWeek = dailyTarget * (7 - skippedDaysThisWeek)
|
||||
val targetThisMonth = dailyTarget * (daysInMonth - skippedDaysThisMonth)
|
||||
val targetThisQuarter = dailyTarget * (daysInQuarter - skippedDaysThisQuarter)
|
||||
val targetThisYear = dailyTarget * (daysInYear - skippedDaysThisYear)
|
||||
|
||||
val values = ArrayList<Double>()
|
||||
if (habit.frequency.denominator <= 1) values.add(valueToday / 1e3)
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.hamcrest.MatcherAssert.assertThat
|
||||
import org.hamcrest.number.IsCloseTo
|
||||
import org.hamcrest.number.OrderingComparison
|
||||
import org.isoron.uhabits.core.BaseUnitTest
|
||||
import org.isoron.uhabits.core.models.Entry.Companion.SKIP
|
||||
import org.isoron.uhabits.core.utils.DateUtils.Companion.getToday
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -381,6 +382,66 @@ class NumericalAtLeastScoreListTest : NumericalScoreListTest() {
|
||||
}
|
||||
}
|
||||
|
||||
class NumericalAtLeastScoreListWithSkipTest : NumericalScoreListTest() {
|
||||
@Before
|
||||
@Throws(Exception::class)
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
habit = fixtures.createEmptyNumericalHabit(NumericalHabitType.AT_LEAST)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getValue() {
|
||||
addEntries(0, 10, 2000)
|
||||
addEntries(10, 11, SKIP)
|
||||
addEntries(11, 15, 2000)
|
||||
addEntries(15, 16, SKIP)
|
||||
addEntries(16, 20, 2000)
|
||||
val expectedValues = doubleArrayOf(
|
||||
0.617008,
|
||||
0.596033,
|
||||
0.573910,
|
||||
0.550574,
|
||||
0.525961,
|
||||
0.500000,
|
||||
0.472617,
|
||||
0.443734,
|
||||
0.413270,
|
||||
0.381137,
|
||||
0.347244, // skipped day should have the same score as the previous day
|
||||
0.347244,
|
||||
0.311495,
|
||||
0.273788,
|
||||
0.234017,
|
||||
0.192067, // skipped day should have the same score as the previous day
|
||||
0.192067,
|
||||
0.147820,
|
||||
0.101149,
|
||||
0.051922,
|
||||
0.000000,
|
||||
0.000000,
|
||||
0.000000
|
||||
)
|
||||
checkScoreValues(expectedValues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipsShouldNotAffectScore() {
|
||||
addEntries(0, 500, 1000)
|
||||
val initialScore = habit.scores[today].value
|
||||
|
||||
addEntries(500, 1000, SKIP)
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(initialScore, E))
|
||||
|
||||
addEntries(0, 300, 1000)
|
||||
addEntries(300, 500, SKIP)
|
||||
addEntries(500, 700, 1000)
|
||||
|
||||
// skipped days should be treated as if they never existed
|
||||
assertThat(habit.scores[today].value, IsCloseTo.closeTo(initialScore, E))
|
||||
}
|
||||
}
|
||||
|
||||
class NumericalAtMostScoreListTest : NumericalScoreListTest() {
|
||||
@Before
|
||||
@Throws(Exception::class)
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.hamcrest.MatcherAssert.assertThat
|
||||
import org.hamcrest.core.IsEqual.equalTo
|
||||
import org.isoron.uhabits.core.BaseUnitTest
|
||||
import org.isoron.uhabits.core.models.Entry
|
||||
import org.isoron.uhabits.core.models.Frequency
|
||||
import org.isoron.uhabits.core.models.Habit
|
||||
import org.isoron.uhabits.core.preferences.Preferences
|
||||
import org.isoron.uhabits.core.utils.DateUtils.Companion.getToday
|
||||
@@ -79,7 +80,14 @@ class ListHabitsBehaviorTest : BaseUnitTest() {
|
||||
@Test
|
||||
fun testOnEdit() {
|
||||
behavior.onEdit(habit2, getToday())
|
||||
verify(screen).showNumberPicker(eq(0.1), eq("miles"), eq(""), eq("Jan 25, 2015"), picker.capture())
|
||||
verify(screen).showNumberPicker(
|
||||
eq(0.1),
|
||||
eq("miles"),
|
||||
eq(""),
|
||||
eq("Jan 25, 2015"),
|
||||
eq(Frequency.DAILY),
|
||||
picker.capture()
|
||||
)
|
||||
picker.lastValue.onNumberPicked(100.0, "")
|
||||
val today = getTodayWithOffset()
|
||||
assertThat(habit2.computedEntries.get(today).value, equalTo(100000))
|
||||
|
||||
Reference in New Issue
Block a user