diff --git a/build.gradle.kts b/build.gradle.kts index c5279d865..750093865 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,6 @@ plugins { val kotlinVersion = "1.9.22" - id("com.android.application") version "8.4.0" apply (false) + id("com.android.application") version "8.5.1" apply (false) id("org.jetbrains.kotlin.android") version kotlinVersion apply (false) id("org.jetbrains.kotlin.kapt") version kotlinVersion apply (false) id("org.jetbrains.kotlin.multiplatform") version kotlinVersion apply (false) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 17655d0ef..48c0a02ca 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/uhabits-android/build.gradle.kts b/uhabits-android/build.gradle.kts index 894c0b4ec..893191dd7 100644 --- a/uhabits-android/build.gradle.kts +++ b/uhabits-android/build.gradle.kts @@ -19,7 +19,7 @@ plugins { id("com.github.triplet.play") version "3.8.6" - id("com.android.application") version "8.4.0" + id("com.android.application") version "8.5.1" id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.kapt") id("org.jlleitschuh.gradle.ktlint") diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt index 3acc19e1c..41d336fa4 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ScoreList.kt @@ -61,6 +61,32 @@ class ScoreList { return result } + /** + * Returns the number of skips after the offset in the interval used to calculate + * the percentage of completed days. + * + * If skips are found in the interval, it expands the interval by the number of skips found + * and repeats this process for the expanded part until no skips are found in an expanded part. + */ + @Synchronized + tailrec fun getNumberOfSkipsByInterval( + values: IntArray, + firstIndexCurrentInterval: Int, + lastIndexCurrentInterval: Int, + nbSkipsIntermedSol: Int = 0 + ): Int { + if (lastIndexCurrentInterval < firstIndexCurrentInterval) return nbSkipsIntermedSol + var nbOfSkips = 0 + var nextLastIndex = lastIndexCurrentInterval + for (i in firstIndexCurrentInterval..lastIndexCurrentInterval) { + if (values[i] == Entry.SKIP) { + nbOfSkips++ + if (lastIndexCurrentInterval + nbOfSkips < values.size) nextLastIndex++ + } + } + return getNumberOfSkipsByInterval(values, lastIndexCurrentInterval + 1, nextLastIndex, nbSkipsIntermedSol + nbOfSkips) + } + /** * Recomputes all scores between the provided [from] and [to] timestamps. */ @@ -125,8 +151,13 @@ class ScoreList { rollingSum += 1.0 } if (offset + denominator < values.size) { - if (values[offset + denominator] == Entry.YES_MANUAL) { - rollingSum -= 1.0 + val nbOfSkips = + getNumberOfSkipsByInterval(values, offset, offset + denominator) + val lastIndexForRollingSum = offset + denominator + nbOfSkips + if (lastIndexForRollingSum < values.size) { + if (values[lastIndexForRollingSum] == Entry.YES_MANUAL) { + if (values[offset] != Entry.SKIP) rollingSum -= 1.0 + } } } if (values[offset] != Entry.SKIP) { diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/ScoreListTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/ScoreListTest.kt index 9b653cd4a..9e128f81d 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/ScoreListTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/ScoreListTest.kt @@ -18,15 +18,19 @@ */ package org.isoron.uhabits.core.models +import junit.framework.Assert.assertTrue 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.models.Entry.Companion.UNKNOWN +import org.isoron.uhabits.core.models.Entry.Companion.YES_MANUAL import org.isoron.uhabits.core.utils.DateUtils.Companion.getToday import org.junit.Before import org.junit.Test import java.util.ArrayList +import kotlin.test.assertEquals import kotlin.test.assertTrue open class BaseScoreListTest : BaseUnitTest() { @@ -145,6 +149,96 @@ class YesNoScoreListTest : BaseScoreListTest() { checkScoreValues(expectedValues) } + @Test + fun test_getNumberOfSkipsByInterval_NoSkips() { + val vars = intArrayOf(UNKNOWN, UNKNOWN, UNKNOWN, UNKNOWN, SKIP, YES_MANUAL, YES_MANUAL, + UNKNOWN, YES_MANUAL, YES_MANUAL, UNKNOWN, YES_MANUAL, YES_MANUAL, UNKNOWN, SKIP, YES_MANUAL) + val nbOfSkips = habit.scores.getNumberOfSkipsByInterval(vars, 5, 13) + assertEquals(0, nbOfSkips) + } + + @Test + fun test_getNumberOfSkipsByInterval_SkipsOnlyInInitialInterval() { + val vars = intArrayOf(UNKNOWN, UNKNOWN, UNKNOWN, UNKNOWN, SKIP, YES_MANUAL, YES_MANUAL, + UNKNOWN, SKIP, SKIP, UNKNOWN, YES_MANUAL, YES_MANUAL, UNKNOWN, SKIP, YES_MANUAL) + val nbOfSkips = habit.scores.getNumberOfSkipsByInterval(vars, 4, 9) + assertEquals(3, nbOfSkips) + } + + @Test + fun test_getNumberOfSkipsByInterval_SkipsInSubsequentIntervals() { + val vars = intArrayOf(UNKNOWN, UNKNOWN, UNKNOWN, UNKNOWN, SKIP, YES_MANUAL, YES_MANUAL, + UNKNOWN, SKIP, SKIP, UNKNOWN, YES_MANUAL, YES_MANUAL, UNKNOWN, SKIP, YES_MANUAL) + val nbOfSkips = habit.scores.getNumberOfSkipsByInterval(vars, 4, 11) + assertEquals(4, nbOfSkips) + } + + @Test + fun test_getValueNonDailyWithSkip() { + habit.frequency = Frequency(6, 7) + check(0, 18) + addSkip(10) + addSkip(11) + addSkip(12) + habit.recompute() + val expectedValues = doubleArrayOf( + 0.365222, + 0.333100, + 0.299354, + 0.263899, + 0.226651, + 0.191734, + 0.159268, + 0.129375, + 0.102187, + 0.077839, + 0.056477, + 0.056477, + 0.056477, + 0.056477, + 0.038251, + 0.023319, + 0.011848, + 0.004014, + 0.000000, + 0.000000, + 0.000000 + ) + checkScoreValues(expectedValues) + } + + @Test + fun test_perfectDailyWithSkips() { + // If the habit is performed daily and the user always either completes or + // skips the habit, score should converge to 100%. + habit.frequency = Frequency(1, 1) + val values = ArrayList() + check(0, 500) + for (k in 0..99) { + addSkip(7 * k + 5) + addSkip(7 * k + 6) + } + habit.recompute() + check(values) + assertThat(habit.scores[today].value, IsCloseTo.closeTo(1.0, E)) + } + + @Test + fun test_perfectNonDailyWithSkips() { + // If the habit is performed six times per week and the user always either completes or + // skips the habit, score should converge to 100%. + habit.frequency = Frequency(6, 7) + val values = ArrayList() + check(0, 500) + for (k in 0..99) { + addSkip(7 * k + 5) + addSkip(7 * k + 6) + } + habit.recompute() + check(values) + assertThat(habit.scores[today].value, IsCloseTo.closeTo(1.0, E)) + } + @Test fun test_imperfectNonDaily() { // If the habit should be performed 3 times per week and the user misses 1 repetition