Write cleaner version of EntryList

pull/699/head
Alinson S. Xavier 5 years ago
parent d9be39b839
commit 94c78ebb72

@ -0,0 +1,229 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
import org.isoron.uhabits.core.models.Entry.Companion.UNKNOWN
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.utils.*
import kotlin.collections.set
import kotlin.math.*
class Entries {
private val entriesByTimestamp: HashMap<Timestamp, Entry> = HashMap()
/**
* Returns the entry corresponding to the given timestamp. If no entry with such timestamp
* has been previously added, returns Entry(timestamp, UNKNOWN).
*/
fun get(timestamp: Timestamp): Entry {
return entriesByTimestamp[timestamp] ?: Entry(timestamp, UNKNOWN)
}
/**
* Returns one entry for each day in the given interval. The first element corresponds to the
* newest entry, and the last element corresponds to the oldest. The interval endpoints are
* included.
*/
fun getByInterval(from: Timestamp, to: Timestamp): List<Entry> {
val result = mutableListOf<Entry>()
var current = to
while (current >= from) {
result.add(get(current))
current = current.minus(1)
}
return result
}
/**
* Adds the given entry to the list. If another entry with the same timestamp already exists,
* replaces it.
*/
fun add(entry: Entry) {
entriesByTimestamp[entry.timestamp] = entry
}
/**
* Returns all entries whose values are known, sorted by timestamp. The first element
* corresponds to the newest entry, and the last element corresponds to the oldest.
*/
fun getKnown(): List<Entry> {
return entriesByTimestamp.values.sortedBy { it.timestamp }.reversed()
}
/**
* Truncates the timestamps of all known entries, then aggregates their values. This function
* is used to generate bar plots where each bar shows the number of repetitions in a given week,
* month or year.
*
* For boolean habits, the value of the aggregated entry equals to the number of YES_MANUAL
* entries. For numerical habits, the value is the total sum. The field [firstWeekday] is only
* relevant when grouping by week.
*/
fun groupBy(
field: DateUtils.TruncateField,
firstWeekday: Int,
isNumerical: Boolean,
): List<Entry> {
val original = getKnown()
val truncated = original.map {
Entry(it.timestamp.truncate(field, firstWeekday), it.value)
}
val timestamps = mutableListOf<Timestamp>()
val values = mutableListOf<Int>()
for (i in truncated.indices) {
if (i == 0 || timestamps.last() != truncated[i].timestamp) {
timestamps.add(truncated[i].timestamp)
values.add(0)
}
if (isNumerical) {
values[values.lastIndex] += truncated[i].value
} else {
if (values[values.lastIndex] == YES_MANUAL) {
values[values.lastIndex] += 1
}
}
}
return timestamps.indices.map { Entry(timestamps[it], values[it]) }
}
/**
* Replaces all entries in this list by entries computed automatically from another list.
*
* For boolean habits, this function creates additional entries (with value YES_AUTO) according
* to the frequency of the habit. For numerical habits, this function simply copies all entries.
*/
fun computeFrom(
other: Entries,
frequency: Frequency,
isNumerical: Boolean,
) {
clear()
val original = other.getKnown()
if (isNumerical) {
original.forEach { add(it) }
} else {
val intervals = buildIntervals(frequency, original)
if (intervals.isEmpty()) return
snapIntervalsTogether(intervals)
val computed = buildEntriesFromInterval(original, intervals)
computed.filter { it.value != UNKNOWN }.forEach { add(it) }
}
}
data class Interval(val begin: Timestamp, val center: Timestamp, val end: Timestamp) {
val length: Int
get() = begin.daysUntil(end) + 1;
}
/**
* Removes all known entries.
*/
fun clear() {
entriesByTimestamp.clear()
}
/**
* Converts a list of intervals into a list of entries. Entries that fall outside of any
* interval receive value UNKNOWN. Entries that fall within an interval but do not appear
* in [original] receive value YES_AUTO. Entries provided in [original] are just copied over.
*
* The intervals should be sorted by timestamp. The first element in the list should
* correspond to the newest interval.
*/
companion object {
fun buildEntriesFromInterval(
original: List<Entry>,
intervals: List<Interval>,
): List<Entry> {
val toTimestamp = intervals.first().end
val fromTimstamp = intervals.last().begin
val result = arrayListOf<Entry>()
// Create unknown entries
var current = toTimestamp
while (current >= fromTimstamp) {
result.add(Entry(current, UNKNOWN))
current = current.minus(1)
}
// Create YES_AUTO entries
intervals.forEach { interval ->
current = interval.end
while (current >= interval.begin) {
val offset = current.daysUntil(toTimestamp)
result[offset] = Entry(current, YES_AUTO)
current = current.minus(1)
}
}
// Copy original entries
original.forEach { entry ->
val offset = entry.timestamp.daysUntil(toTimestamp)
result[offset] = entry
}
return result
}
/**
* Starting from the second newest interval, this function tries to slide the
* intervals backwards into the past, so that gaps are eliminated and
* streaks are maximized.
*
* The intervals should be sorted by timestamp. The first element in the list should
* correspond to the newest interval.
*/
fun snapIntervalsTogether(intervals: ArrayList<Interval>) {
for (i in 1 until intervals.size) {
val curr = intervals[i]
val next = intervals[i - 1]
val gapNextToCurrent = next.begin.daysUntil(curr.end)
val gapCenterToEnd = curr.center.daysUntil(curr.end)
if (gapNextToCurrent >= 0) {
val shift = min(gapCenterToEnd, gapNextToCurrent + 1)
intervals[i] = Interval(curr.begin.minus(shift),
curr.center,
curr.end.minus(shift))
}
}
}
fun buildIntervals(
freq: Frequency,
entries: List<Entry>,
): ArrayList<Interval> {
val filtered = entries.filter { it.value == YES_MANUAL }
val num = freq.numerator
val den = freq.denominator
val intervals = arrayListOf<Interval>()
for (i in num - 1 until filtered.size) {
val (begin, _) = filtered[i]
val (center, _) = filtered[i - num + 1]
if (begin.daysUntil(center) < den) {
val end = begin.plus(den - 1)
intervals.add(Interval(begin, center, end))
}
}
return intervals
}
}
}

@ -158,7 +158,7 @@ public class EntryList
{ {
list.addAll(entries); list.addAll(entries);
Collections.sort(list, Collections.sort(list,
(c1, c2) -> c2.getTimestamp().compare(c1.getTimestamp())); (c1, c2) -> c2.getTimestamp().compareTo(c1.getTimestamp()));
} }

@ -74,7 +74,7 @@ public class RepetitionList
} }
Collections.sort(filtered, Collections.sort(filtered,
(r1, r2) -> r1.getTimestamp().compare(r2.getTimestamp())); (r1, r2) -> r1.getTimestamp().compareTo(r2.getTimestamp()));
return filtered; return filtered;
} }

@ -73,7 +73,7 @@ public final class Score
public int compareNewer(Score other) public int compareNewer(Score other)
{ {
return getTimestamp().compare(other.getTimestamp()); return getTimestamp().compareTo(other.getTimestamp());
} }
public Timestamp getTimestamp() public Timestamp getTimestamp()

@ -45,7 +45,7 @@ public final class Streak
public int compareNewer(Streak other) public int compareNewer(Streak other)
{ {
return end.compare(other.end); return end.compareTo(other.end);
} }
public Timestamp getEnd() public Timestamp getEnd()

@ -20,15 +20,13 @@
package org.isoron.uhabits.core.models; package org.isoron.uhabits.core.models;
import org.apache.commons.lang3.builder.*; import org.apache.commons.lang3.builder.*;
import org.isoron.uhabits.core.utils.DateFormats; import org.isoron.uhabits.core.utils.*;
import org.isoron.uhabits.core.utils.DateUtils;
import java.util.*; import java.util.*;
import static java.util.Calendar.*; import static java.util.Calendar.*;
import static org.isoron.uhabits.core.utils.StringUtils.*;
public final class Timestamp public final class Timestamp implements Comparable<Timestamp>
{ {
public static final long DAY_LENGTH = 86400000; public static final long DAY_LENGTH = 86400000;
@ -53,6 +51,13 @@ public final class Timestamp
this(cal.getTimeInMillis()); this(cal.getTimeInMillis());
} }
public static Timestamp from(int year, int javaMonth, int day)
{
GregorianCalendar cal = DateUtils.getStartOfTodayCalendar();
cal.set(year, javaMonth, day, 0, 0, 0);
return new Timestamp(cal.getTimeInMillis());
}
public long getUnixTime() public long getUnixTime()
{ {
return unixTime; return unixTime;
@ -62,7 +67,8 @@ public final class Timestamp
* Returns -1 if this timestamp is older than the given timestamp, 1 if this * Returns -1 if this timestamp is older than the given timestamp, 1 if this
* timestamp is newer, or zero if they are equal. * timestamp is newer, or zero if they are equal.
*/ */
public int compare(Timestamp other) @Override
public int compareTo(Timestamp other)
{ {
return Long.signum(this.unixTime - other.unixTime); return Long.signum(this.unixTime - other.unixTime);
} }
@ -117,12 +123,12 @@ public final class Timestamp
public boolean isNewerThan(Timestamp other) public boolean isNewerThan(Timestamp other)
{ {
return compare(other) > 0; return compareTo(other) > 0;
} }
public boolean isOlderThan(Timestamp other) public boolean isOlderThan(Timestamp other)
{ {
return compare(other) < 0; return compareTo(other) < 0;
} }

@ -40,7 +40,7 @@ public class MemoryScoreList extends ScoreList
{ {
list.addAll(scores); list.addAll(scores);
Collections.sort(list, Collections.sort(list,
(s1, s2) -> s2.getTimestamp().compare(s1.getTimestamp())); (s1, s2) -> s2.getTimestamp().compareTo(s1.getTimestamp()));
getObservable().notifyListeners(); getObservable().notifyListeners();
} }

@ -0,0 +1,302 @@
/*
* Copyright (C) 2016-2020 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.core.models
import org.hamcrest.MatcherAssert.*
import org.hamcrest.core.IsEqual.*
import org.isoron.uhabits.core.models.Entry.Companion.NO
import org.isoron.uhabits.core.models.Entry.Companion.UNKNOWN
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.utils.*
import org.junit.*
import java.util.*
import kotlin.test.*
class EntriesTest {
@Test
fun testEmptyList() {
val entries = Entries()
val today = DateUtils.getToday()
assertEquals(Entry(today.minus(0), UNKNOWN), entries.get(today.minus(0)))
assertEquals(Entry(today.minus(2), UNKNOWN), entries.get(today.minus(2)))
assertEquals(Entry(today.minus(5), UNKNOWN), entries.get(today.minus(5)))
entries.add(Entry(today.minus(0), 10))
entries.add(Entry(today.minus(0), 15)) // replace previous one
entries.add(Entry(today.minus(5), 20))
entries.add(Entry(today.minus(8), 30))
assertEquals(Entry(today.minus(0), 15), entries.get(today.minus(0)))
assertEquals(Entry(today.minus(5), 20), entries.get(today.minus(5)))
assertEquals(Entry(today.minus(8), 30), entries.get(today.minus(8)))
val known = entries.getKnown()
assertEquals(3, known.size)
assertEquals(Entry(today.minus(0), 15), known[0])
assertEquals(Entry(today.minus(5), 20), known[1])
assertEquals(Entry(today.minus(8), 30), known[2])
val actual = entries.getByInterval(today.minus(5), today)
assertEquals(6, actual.size)
assertEquals(Entry(today.minus(0), 15), actual[0])
assertEquals(Entry(today.minus(1), UNKNOWN), actual[1])
assertEquals(Entry(today.minus(2), UNKNOWN), actual[2])
assertEquals(Entry(today.minus(3), UNKNOWN), actual[3])
assertEquals(Entry(today.minus(4), UNKNOWN), actual[4])
assertEquals(Entry(today.minus(5), 20), actual[5])
}
@Test
fun testComputeBoolean() {
val today = DateUtils.getToday()
val original = Entries()
original.add(Entry(today.minus(4), YES_MANUAL))
original.add(Entry(today.minus(9), YES_MANUAL))
original.add(Entry(today.minus(10), YES_MANUAL))
val computed = Entries()
computed.computeFrom(original, Frequency(1, 3), isNumerical = false)
val expected = listOf(
Entry(today.minus(2), YES_AUTO),
Entry(today.minus(3), YES_AUTO),
Entry(today.minus(4), YES_MANUAL),
Entry(today.minus(7), YES_AUTO),
Entry(today.minus(8), YES_AUTO),
Entry(today.minus(9), YES_MANUAL),
Entry(today.minus(10), YES_MANUAL),
Entry(today.minus(11), YES_AUTO),
Entry(today.minus(12), YES_AUTO),
)
assertEquals(expected, computed.getKnown())
// Second call should replace all previously added entries
computed.computeFrom(Entries(), Frequency(1, 3), isNumerical = false)
assertEquals(listOf(), computed.getKnown())
}
@Test
fun testComputeNumerical() {
val today = DateUtils.getToday()
val original = Entries()
original.add(Entry(today.minus(4), 100))
original.add(Entry(today.minus(9), 200))
original.add(Entry(today.minus(10), 300))
val computed = Entries()
computed.computeFrom(original, Frequency.DAILY, isNumerical = true)
val expected = listOf(
Entry(today.minus(4), 100),
Entry(today.minus(9), 200),
Entry(today.minus(10), 300),
)
assertEquals(expected, computed.getKnown())
}
@Test
fun testGroupBy() {
val offsets = intArrayOf(
0, 5, 9, 15, 17, 21, 23, 27, 28, 35, 41, 45, 47, 53, 56, 62, 70, 73, 78,
83, 86, 94, 101, 106, 113, 114, 120, 126, 130, 133, 141, 143, 148, 151, 157, 164,
166, 171, 173, 176, 179, 183, 191, 259, 264, 268, 270, 275, 282, 284, 289, 295,
302, 306, 310, 315, 323, 325, 328, 335, 343, 349, 351, 353, 357, 359, 360, 367,
372, 376, 380, 385, 393, 400, 404, 412, 415, 418, 422, 425, 433, 437, 444, 449,
455, 460, 462, 465, 470, 471, 479, 481, 485, 489, 494, 495, 500, 501, 503, 507)
val values = intArrayOf(
230, 306, 148, 281, 134, 285, 104, 158, 325, 236, 303, 210, 118, 124,
301, 201, 156, 376, 347, 367, 396, 134, 160, 381, 155, 354, 231, 134, 164, 354,
236, 398, 199, 221, 208, 397, 253, 276, 214, 341, 299, 221, 353, 250, 341, 168,
374, 205, 182, 217, 297, 321, 104, 237, 294, 110, 136, 229, 102, 271, 250, 294,
158, 319, 379, 126, 282, 155, 288, 159, 215, 247, 207, 226, 244, 158, 371, 219,
272, 228, 350, 153, 356, 279, 394, 202, 213, 214, 112, 248, 139, 245, 165, 256,
370, 187, 208, 231, 341, 312)
val reference = Timestamp.from(2014, Calendar.JUNE, 1)
val entries = Entries()
offsets.indices.forEach {
entries.add(Entry(reference.minus(offsets[it]), values[it]))
}
val byMonth = entries.groupBy(
field = DateUtils.TruncateField.MONTH,
firstWeekday = Calendar.SATURDAY,
isNumerical = true,
)
assertThat(byMonth.size, equalTo(17))
assertThat(byMonth[0], equalTo(Entry(Timestamp.from(2014, Calendar.JUNE, 1), 230)))
assertThat(byMonth[6], equalTo(Entry(Timestamp.from(2013, Calendar.DECEMBER, 1), 1988)))
assertThat(byMonth[12], equalTo(Entry(Timestamp.from(2013, Calendar.MAY, 1), 1271)))
val byQuarter = entries.groupBy(
field = DateUtils.TruncateField.QUARTER,
firstWeekday = Calendar.SATURDAY,
isNumerical = true,
)
assertThat(byQuarter.size, equalTo(6))
assertThat(byQuarter[0], equalTo(Entry(Timestamp.from(2014, Calendar.APRIL, 1), 3263)))
assertThat(byQuarter[3], equalTo(Entry(Timestamp.from(2013, Calendar.JULY, 1), 3838)))
assertThat(byQuarter[5], equalTo(Entry(Timestamp.from(2013, Calendar.JANUARY, 1), 4975)))
val byYear = entries.groupBy(
field = DateUtils.TruncateField.YEAR,
firstWeekday = Calendar.SATURDAY,
isNumerical = true,
)
assertThat(byYear.size, equalTo(2))
assertThat(byYear[0], equalTo(Entry(Timestamp.from(2014, Calendar.JANUARY, 1), 8227)))
assertThat(byYear[1], equalTo(Entry(Timestamp.from(2013, Calendar.JANUARY, 1), 16172)))
}
@Test
fun testAddFromInterval() {
val entries = listOf(
Entry(day(1), YES_MANUAL),
Entry(day(2), NO),
Entry(day(5), YES_MANUAL),
Entry(day(10), YES_MANUAL),
)
val intervals = listOf(
Entries.Interval(day(2), day(2), day(1)),
Entries.Interval(day(6), day(5), day(4)),
Entries.Interval(day(10), day(8), day(8)),
)
val expected = listOf(
Entry(day(1), YES_MANUAL),
Entry(day(2), NO),
Entry(day(3), UNKNOWN),
Entry(day(4), YES_AUTO),
Entry(day(5), YES_MANUAL),
Entry(day(6), YES_AUTO),
Entry(day(7), UNKNOWN),
Entry(day(8), YES_AUTO),
Entry(day(9), YES_AUTO),
Entry(day(10), YES_MANUAL),
)
val actual = Entries.buildEntriesFromInterval(entries, intervals)
assertThat(actual, equalTo(expected))
}
@Test
fun testSnapIntervalsTogether1() {
val original = arrayListOf(
Entries.Interval(day(8), day(8), day(2)),
Entries.Interval(day(12), day(12), day(6)),
Entries.Interval(day(20), day(20), day(14)),
Entries.Interval(day(27), day(27), day(21)),
)
val expected = arrayListOf(
Entries.Interval(day(8), day(8), day(2)),
Entries.Interval(day(15), day(12), day(9)),
Entries.Interval(day(22), day(20), day(16)),
Entries.Interval(day(29), day(27), day(23)),
)
Entries.snapIntervalsTogether(original)
assertThat(original, equalTo(expected))
}
@Test
fun testSnapIntervalsTogether2() {
val original = arrayListOf(
Entries.Interval(day(6), day(4), day(0)),
Entries.Interval(day(11), day(8), day(5)),
)
val expected = arrayListOf(
Entries.Interval(day(6), day(4), day(0)),
Entries.Interval(day(13), day(8), day(7)),
)
Entries.snapIntervalsTogether(original)
assertThat(original, equalTo(expected))
}
@Test
fun testBuildIntervals1() {
val entries = listOf(
Entry(day(8), YES_MANUAL),
Entry(day(18), YES_MANUAL),
Entry(day(23), YES_MANUAL),
)
val expected = listOf(
Entries.Interval(day(8), day(8), day(2)),
Entries.Interval(day(18), day(18), day(12)),
Entries.Interval(day(23), day(23), day(17)),
)
val actual = Entries.buildIntervals(Frequency.WEEKLY, entries)
assertThat(actual, equalTo(expected))
}
@Test
fun testBuildIntervals2() {
val entries = listOf(
Entry(day(8), YES_MANUAL),
Entry(day(18), YES_MANUAL),
Entry(day(23), YES_MANUAL),
)
val expected = listOf(
Entries.Interval(day(8), day(8), day(8)),
Entries.Interval(day(18), day(18), day(18)),
Entries.Interval(day(23), day(23), day(23)),
)
val actual = Entries.buildIntervals(Frequency.DAILY, entries)
assertThat(actual, equalTo(expected))
}
@Test
fun testBuildIntervals3() {
val entries = listOf(
Entry(day(8), YES_MANUAL),
Entry(day(15), YES_MANUAL),
Entry(day(18), YES_MANUAL),
Entry(day(22), YES_MANUAL),
Entry(day(23), YES_MANUAL),
)
val expected = listOf(
Entries.Interval(day(18), day(15), day(12)),
Entries.Interval(day(22), day(18), day(16)),
Entries.Interval(day(23), day(22), day(17)),
)
val actual = Entries.buildIntervals(Frequency.TWO_TIMES_PER_WEEK, entries)
assertThat(actual, equalTo(expected))
}
@Test
fun testBuildIntervals4() {
val entries = listOf(
Entry(day(10), YES_MANUAL),
Entry(day(20), Entry.SKIP),
Entry(day(30), YES_MANUAL),
)
val expected = listOf(
Entries.Interval(day(10), day(10), day(8)),
Entries.Interval(day(30), day(30), day(28)),
)
val actual = Entries.buildIntervals(Frequency(1, 3), entries)
assertThat(actual, equalTo(expected))
}
fun day(offset: Int) = DateUtils.getToday().minus(offset)
}

@ -22,7 +22,6 @@ package org.isoron.uhabits.core.models;
import org.isoron.uhabits.core.*; import org.isoron.uhabits.core.*;
import org.isoron.uhabits.core.utils.*; import org.isoron.uhabits.core.utils.*;
import org.junit.*; import org.junit.*;
import org.mockito.internal.verification.*;
import static junit.framework.TestCase.assertFalse; import static junit.framework.TestCase.assertFalse;
import static org.hamcrest.MatcherAssert.*; import static org.hamcrest.MatcherAssert.*;
@ -38,9 +37,9 @@ public class TimestampTest extends BaseUnitTest
Timestamp t2 = t1.minus(1); Timestamp t2 = t1.minus(1);
Timestamp t3 = t1.plus(3); Timestamp t3 = t1.plus(3);
assertThat(t1.compare(t2), greaterThan(0)); assertThat(t1.compareTo(t2), greaterThan(0));
assertThat(t1.compare(t1), equalTo(0)); assertThat(t1.compareTo(t1), equalTo(0));
assertThat(t1.compare(t3), lessThan(0)); assertThat(t1.compareTo(t3), lessThan(0));
assertTrue(t1.isNewerThan(t2)); assertTrue(t1.isNewerThan(t2));
assertFalse(t1.isNewerThan(t1)); assertFalse(t1.isNewerThan(t1));

Loading…
Cancel
Save