diff --git a/app/src/androidTest/java/org/isoron/uhabits/io/HabitsCSVExporterTest.java b/app/src/androidTest/java/org/isoron/uhabits/io/HabitsCSVExporterTest.java index 58c160aee..4a82c1239 100644 --- a/app/src/androidTest/java/org/isoron/uhabits/io/HabitsCSVExporterTest.java +++ b/app/src/androidTest/java/org/isoron/uhabits/io/HabitsCSVExporterTest.java @@ -75,6 +75,8 @@ public class HabitsCSVExporterTest extends BaseAndroidTest assertPathExists("001 Wake up early/Scores.csv"); assertPathExists("002 Meditate/Checkmarks.csv"); assertPathExists("002 Meditate/Scores.csv"); + assertPathExists("Checkmarks.csv"); + assertPathExists("Scores.csv"); } private void assertAbsolutePathExists(String s) diff --git a/app/src/androidTest/java/org/isoron/uhabits/models/sqlite/SQLiteScoreListTest.java b/app/src/androidTest/java/org/isoron/uhabits/models/sqlite/SQLiteScoreListTest.java index 37b3c06a7..db3f1aaa8 100644 --- a/app/src/androidTest/java/org/isoron/uhabits/models/sqlite/SQLiteScoreListTest.java +++ b/app/src/androidTest/java/org/isoron/uhabits/models/sqlite/SQLiteScoreListTest.java @@ -101,6 +101,30 @@ public class SQLiteScoreListTest extends BaseAndroidTest assertThat(records.get(0).timestamp, equalTo(today)); } + @Test + public void testGetByInterval() + { + long from = today - 10 * day; + long to = today - 3 * day; + + List list = scores.getByInterval(from, to); + assertThat(list.size(), equalTo(8)); + + assertThat(list.get(0).getTimestamp(), equalTo(today - 3 * day)); + assertThat(list.get(3).getTimestamp(), equalTo(today - 6 * day)); + assertThat(list.get(7).getTimestamp(), equalTo(today - 10 * day)); + } + + @Test + public void testGetByInterval_withLongInterval() + { + long from = today - 200 * day; + long to = today; + + List list = scores.getByInterval(from, to); + assertThat(list.size(), equalTo(201)); + } + private List getAllRecords() { return new Select() @@ -109,5 +133,4 @@ public class SQLiteScoreListTest extends BaseAndroidTest .orderBy("timestamp desc") .execute(); } - } diff --git a/app/src/main/java/org/isoron/uhabits/io/HabitsCSVExporter.java b/app/src/main/java/org/isoron/uhabits/io/HabitsCSVExporter.java index 8f764fdfd..fc0a29c92 100644 --- a/app/src/main/java/org/isoron/uhabits/io/HabitsCSVExporter.java +++ b/app/src/main/java/org/isoron/uhabits/io/HabitsCSVExporter.java @@ -41,6 +41,10 @@ public class HabitsCSVExporter private List generateFilenames; private String exportDirName; + /** + * Delimiter used in a CSV file. + */ + private final String DELIMITER = ","; @NonNull private final HabitList allHabits; @@ -102,16 +106,6 @@ public class HabitsCSVExporter return s.substring(0, Math.min(s.length(), 100)); } - private void writeCheckmarks(String habitDirName, CheckmarkList checkmarks) - throws IOException - { - String filename = habitDirName + "Checkmarks.csv"; - FileWriter out = new FileWriter(exportDirName + filename); - generateFilenames.add(filename); - checkmarks.writeCSV(out); - out.close(); - } - private void writeHabits() throws IOException { String filename = "Habits.csv"; @@ -134,6 +128,8 @@ public class HabitsCSVExporter writeScores(habitDirName, h.getScores()); writeCheckmarks(habitDirName, h.getCheckmarks()); } + + writeMultipleHabits(); } private void writeScores(String habitDirName, ScoreList scores) @@ -146,6 +142,118 @@ public class HabitsCSVExporter out.close(); } + private void writeCheckmarks(String habitDirName, CheckmarkList checkmarks) + throws IOException + { + String filename = habitDirName + "Checkmarks.csv"; + FileWriter out = new FileWriter(exportDirName + filename); + generateFilenames.add(filename); + checkmarks.writeCSV(out); + out.close(); + } + + /** + * Writes a scores file and a checkmarks file containing scores and checkmarks of every habit. + * The first column corresponds to the date. Subsequent columns correspond to a habit. + * Habits are taken from the list of selected habits. + * Dates are determined from the oldest repetition date to the newest repetition date found in + * the list of habits. + * + * @throws IOException if there was problem writing the files + */ + private void writeMultipleHabits() throws IOException + { + String scoresFileName = "Scores.csv"; + String checksFileName = "Checkmarks.csv"; + generateFilenames.add(scoresFileName); + generateFilenames.add(checksFileName); + FileWriter scoresWriter = new FileWriter(exportDirName + scoresFileName); + FileWriter checksWriter = new FileWriter(exportDirName + checksFileName); + + writeMultipleHabitsHeader(scoresWriter); + writeMultipleHabitsHeader(checksWriter); + + long[] timeframe = getTimeframe(); + long oldest = timeframe[0]; + long newest = DateUtils.getStartOfToday(); + + List checkmarks = new ArrayList<>(); + List scores = new ArrayList<>(); + for (Habit h : selectedHabits) + { + checkmarks.add(h.getCheckmarks().getValues(oldest, newest)); + scores.add(h.getScores().getValues(oldest, newest)); + } + + int days = DateUtils.getDaysBetween(oldest, newest); + SimpleDateFormat dateFormat = DateFormats.getCSVDateFormat(); + for (int i = 0; i <= days; i++) + { + Date day = new Date(newest - i * DateUtils.millisecondsInOneDay); + + String date = dateFormat.format(day); + StringBuilder sb = new StringBuilder(); + sb.append(date).append(DELIMITER); + checksWriter.write(sb.toString()); + scoresWriter.write(sb.toString()); + + for(int j = 0; j < selectedHabits.size(); j++) + { + checksWriter.write(String.valueOf(checkmarks.get(j)[i])); + checksWriter.write(DELIMITER); + String score = + String.format("%.4f", ((float) scores.get(j)[i]) / Score.MAX_VALUE); + scoresWriter.write(score); + scoresWriter.write(DELIMITER); + } + checksWriter.write("\n"); + scoresWriter.write("\n"); + } + scoresWriter.close(); + checksWriter.close(); + } + + /** + * Writes the first row, containing header information, using the given writer. + * This consists of the date title and the names of the selected habits. + * + * @param out the writer to use + * @throws IOException if there was a problem writing + */ + private void writeMultipleHabitsHeader(Writer out) throws IOException + { + out.write("Date" + DELIMITER); + for (Habit h : selectedHabits) { + out.write(h.getName()); + out.write(DELIMITER); + } + out.write("\n"); + } + + /** + * Gets the overall timeframe of the selected habits. + * The timeframe is an array containing the oldest timestamp among the habits and the + * newest timestamp among the habits. + * Both timestamps are in milliseconds. + * + * @return the timeframe containing the oldest timestamp and the newest timestamp + */ + private long[] getTimeframe() + { + long oldest = Long.MAX_VALUE; + long newest = -1; + for (Habit h : selectedHabits) + { + if(h.getRepetitions().getOldest() == null || h.getRepetitions().getNewest() == null) + continue; + long currOld = h.getRepetitions().getOldest().getTimestamp(); + long currNew = h.getRepetitions().getNewest().getTimestamp(); + oldest = currOld > oldest ? oldest : currOld; + newest = currNew < newest ? newest : currNew; + } + return new long[]{oldest, newest}; + } + private String writeZipFile() throws IOException { SimpleDateFormat dateFormat = DateFormats.getCSVDateFormat(); diff --git a/app/src/main/java/org/isoron/uhabits/models/RepetitionList.java b/app/src/main/java/org/isoron/uhabits/models/RepetitionList.java index 07a9df1ad..07fa7b681 100644 --- a/app/src/main/java/org/isoron/uhabits/models/RepetitionList.java +++ b/app/src/main/java/org/isoron/uhabits/models/RepetitionList.java @@ -107,6 +107,16 @@ public abstract class RepetitionList */ @Nullable public abstract Repetition getOldest(); + @Nullable + /** + * Returns the newest repetition in the list. + *

+ * If the list is empty, returns null. Repetitions in the past are + * discarded. + * + * @return newest repetition in the list, or null if list is empty. + */ + public abstract Repetition getNewest(); /** * Returns the total number of repetitions for each month, from the first diff --git a/app/src/main/java/org/isoron/uhabits/models/ScoreList.java b/app/src/main/java/org/isoron/uhabits/models/ScoreList.java index 126058d7b..0891cc01d 100644 --- a/app/src/main/java/org/isoron/uhabits/models/ScoreList.java +++ b/app/src/main/java/org/isoron/uhabits/models/ScoreList.java @@ -89,6 +89,46 @@ public abstract class ScoreList implements Iterable return s.getValue(); } + /** + * Returns the list of scores that fall within the given interval. + *

+ * There is exactly one score per day in the interval. The endpoints of + * the interval are included. The list is ordered by timestamp (decreasing). + * That is, the first score corresponds to the newest timestamp, and the + * last score corresponds to the oldest timestamp. + * + * @param fromTimestamp timestamp of the beginning of the interval. + * @param toTimestamp timestamp of the end of the interval. + * @return the list of scores within the interval. + */ + @NonNull + public abstract List getByInterval(long fromTimestamp, + long toTimestamp); + + /** + * Returns the values of the scores that fall inside a certain interval + * of time. + *

+ * The values are returned in an array containing one integer value for each + * day of the interval. The first entry corresponds to the most recent day + * in the interval. Each subsequent entry corresponds to one day older than + * the previous entry. The boundaries of the time interval are included. + * + * @param from timestamp for the oldest score + * @param to timestamp for the newest score + * @return values for the scores inside the given interval + */ + public final int[] getValues(long from, long to) + { + List scores = getByInterval(from, to); + int[] values = new int[scores.size()]; + + for(int i = 0; i < values.length; i++) + values[i] = scores.get(i).getValue(); + + return values; + } + public List groupBy(DateUtils.TruncateField field) { computeAll(); diff --git a/app/src/main/java/org/isoron/uhabits/models/memory/MemoryRepetitionList.java b/app/src/main/java/org/isoron/uhabits/models/memory/MemoryRepetitionList.java index 8e55f84fc..dbac82b40 100644 --- a/app/src/main/java/org/isoron/uhabits/models/memory/MemoryRepetitionList.java +++ b/app/src/main/java/org/isoron/uhabits/models/memory/MemoryRepetitionList.java @@ -92,6 +92,26 @@ public class MemoryRepetitionList extends RepetitionList return oldestRep; } + @Nullable + @Override + public Repetition getNewest() + { + long newestTime = -1; + Repetition newestRep = null; + + for (Repetition rep : list) + { + if (rep.getTimestamp() > newestTime) + { + newestRep = rep; + newestTime = rep.getTimestamp(); + } + + } + + return newestRep; + } + @Override public void remove(@NonNull Repetition repetition) { diff --git a/app/src/main/java/org/isoron/uhabits/models/memory/MemoryScoreList.java b/app/src/main/java/org/isoron/uhabits/models/memory/MemoryScoreList.java index 0fafe0e8d..5dafb166d 100644 --- a/app/src/main/java/org/isoron/uhabits/models/memory/MemoryScoreList.java +++ b/app/src/main/java/org/isoron/uhabits/models/memory/MemoryScoreList.java @@ -43,6 +43,21 @@ public class MemoryScoreList extends ScoreList (s1, s2) -> Long.signum(s2.getTimestamp() - s1.getTimestamp())); } + @NonNull + @Override + public List getByInterval(long fromTimestamp, long toTimestamp) + { + compute(fromTimestamp, toTimestamp); + + List filtered = new LinkedList<>(); + + for (Score s : list) + if (s.getTimestamp() >= fromTimestamp && + s.getTimestamp() <= toTimestamp) filtered.add(s); + + return filtered; + } + @Nullable @Override public Score getComputedByTimestamp(long timestamp) diff --git a/app/src/main/java/org/isoron/uhabits/models/sqlite/SQLiteRepetitionList.java b/app/src/main/java/org/isoron/uhabits/models/sqlite/SQLiteRepetitionList.java index aa3594edf..6278863e9 100644 --- a/app/src/main/java/org/isoron/uhabits/models/sqlite/SQLiteRepetitionList.java +++ b/app/src/main/java/org/isoron/uhabits/models/sqlite/SQLiteRepetitionList.java @@ -125,6 +125,24 @@ public class SQLiteRepetitionList extends RepetitionList return record.toRepetition(); } + @Override + public Repetition getNewest() + { + check(habit.getId()); + String query = "select habit, timestamp " + + "from Repetitions " + + "where habit = ? " + + "order by timestamp desc " + + "limit 1"; + + String params[] = { Long.toString(habit.getId()) }; + + RepetitionRecord record = sqlite.querySingle(query, params); + if (record == null) return null; + record.habit = habitRecord; + return record.toRepetition(); + } + @Override public void remove(@NonNull Repetition repetition) { diff --git a/app/src/main/java/org/isoron/uhabits/models/sqlite/SQLiteScoreList.java b/app/src/main/java/org/isoron/uhabits/models/sqlite/SQLiteScoreList.java index 5ef6d54e8..e44e7e6ed 100644 --- a/app/src/main/java/org/isoron/uhabits/models/sqlite/SQLiteScoreList.java +++ b/app/src/main/java/org/isoron/uhabits/models/sqlite/SQLiteScoreList.java @@ -84,6 +84,29 @@ public class SQLiteScoreList extends ScoreList } } + @NonNull + @Override + public List getByInterval(long fromTimestamp, long toTimestamp) + { + check(habit.getId()); + compute(fromTimestamp, toTimestamp); + + String query = "select habit, timestamp, score " + + "from Score " + + "where habit = ? and timestamp >= ? and timestamp <= ? " + + "order by timestamp desc"; + + String params[] = { + Long.toString(habit.getId()), + Long.toString(fromTimestamp), + Long.toString(toTimestamp) + }; + + List records = sqlite.query(query, params); + for (ScoreRecord record : records) record.habit = habitRecord; + return toScores(records); + } + @Override @Nullable public Score getComputedByTimestamp(long timestamp) @@ -127,11 +150,7 @@ public class SQLiteScoreList extends ScoreList List records = sqlite.query(query, params); for (ScoreRecord record : records) record.habit = habitRecord; - List scores = new LinkedList<>(); - for (ScoreRecord rec : records) - scores.add(rec.toScore()); - - return scores; + return toScores(records); } @Nullable @@ -177,4 +196,12 @@ public class SQLiteScoreList extends ScoreList record.habit = habitRecord; return record.toScore(); } + + @NonNull + private List toScores(@NonNull List records) + { + List scores = new LinkedList<>(); + for (ScoreRecord r : records) scores.add(r.toScore()); + return scores; + } } diff --git a/app/src/main/java/org/isoron/uhabits/utils/DateUtils.java b/app/src/main/java/org/isoron/uhabits/utils/DateUtils.java index 5684f5302..c85f90df2 100644 --- a/app/src/main/java/org/isoron/uhabits/utils/DateUtils.java +++ b/app/src/main/java/org/isoron/uhabits/utils/DateUtils.java @@ -272,4 +272,18 @@ public abstract class DateUtils { MONTH, WEEK_NUMBER, YEAR, QUARTER } + + /** + * Gets the number of days between two timestamps (exclusively). + * + * @param t1 the first timestamp to use in milliseconds + * @param t2 the second timestamp to use in milliseconds + * @return the number of days between the two timestamps + */ + public static int getDaysBetween(long t1, long t2) + { + Date d1 = new Date(t1); + Date d2 = new Date(t2); + return (int) (Math.abs((d2.getTime() - d1.getTime()) / millisecondsInOneDay)); + } } diff --git a/app/src/test/java/org/isoron/uhabits/models/ScoreListTest.java b/app/src/test/java/org/isoron/uhabits/models/ScoreListTest.java index 329bc207c..08b999114 100644 --- a/app/src/test/java/org/isoron/uhabits/models/ScoreListTest.java +++ b/app/src/test/java/org/isoron/uhabits/models/ScoreListTest.java @@ -174,6 +174,27 @@ public class ScoreListTest extends BaseUnitTest assertThat(writer.toString(), equalTo(expectedCSV)); } + @Test + public void test_getValues() + { + toggleRepetitions(0, 20); + + long today = DateUtils.getStartOfToday(); + long day = DateUtils.millisecondsInOneDay; + + long from = today - 4 * day; + long to = today - 2 * day; + + int[] expected = { + 11883254, + 11479288, + 11053198, + }; + + int[] actual = habit.getScores().getValues(from, to); + assertThat(actual, equalTo(expected)); + } + private void toggleRepetitions(final int from, final int to) { RepetitionList reps = habit.getRepetitions(); diff --git a/app/src/test/java/org/isoron/uhabits/utils/DateUtilsTest.java b/app/src/test/java/org/isoron/uhabits/utils/DateUtilsTest.java index 1bc2481fe..910b251b5 100644 --- a/app/src/test/java/org/isoron/uhabits/utils/DateUtilsTest.java +++ b/app/src/test/java/org/isoron/uhabits/utils/DateUtilsTest.java @@ -134,4 +134,14 @@ public class DateUtilsTest extends BaseUnitTest assertThat(DateUtils.truncate(field, t1), equalTo(expected)); assertThat(DateUtils.truncate(field, t2), equalTo(expected)); } + + @Test + public void test_getDaysBetween() + { + long t1 = timestamp(2016, JANUARY, 1); + long t2 = timestamp(2016, DECEMBER, 31); + int expected = 365; + assertThat(DateUtils.getDaysBetween(t1, t2), equalTo(expected)); + assertThat(DateUtils.getDaysBetween(t2, t1), equalTo(expected)); + } }