From ec340430419bcc457429a4fcbaf876fe300b8066 Mon Sep 17 00:00:00 2001 From: jotran Date: Fri, 16 Sep 2016 21:06:36 -0700 Subject: [PATCH] Add exporting scores and checkmarks files with multiple habits (#68). Add writeMultipleHabits to write a scores file and a checkmarks file that contains scores and checkmarks of multiple habits. Add getTimeframe because it was necessary to get the timeframe between all habits so that row data could be populated correctly for habits that started before/after other habits. Move writeCheckmarks to below writeScores since it wasn't called until after writeScores in writeHabits. Add getByInterval to ScoreList, MemoryScoreList, and SQLiteScoreList to get scores between a given interval - simiarly to CheckmarkList. Add getValues (ScoreList) to get all values between a given timeframe. Add getNewest to get the newest repetition in a list and to correspond with getOldest. Add getDaysBetween to DateUtils to quickly get the number of days between two timestamps. Add tests for the new functions. --- .../uhabits/io/HabitsCSVExporterTest.java | 2 + .../models/sqlite/SQLiteScoreListTest.java | 25 +++- .../isoron/uhabits/io/HabitsCSVExporter.java | 128 ++++++++++++++++-- .../isoron/uhabits/models/RepetitionList.java | 10 ++ .../org/isoron/uhabits/models/ScoreList.java | 40 ++++++ .../models/memory/MemoryRepetitionList.java | 20 +++ .../models/memory/MemoryScoreList.java | 15 ++ .../models/sqlite/SQLiteRepetitionList.java | 18 +++ .../models/sqlite/SQLiteScoreList.java | 37 ++++- .../org/isoron/uhabits/utils/DateUtils.java | 14 ++ .../isoron/uhabits/models/ScoreListTest.java | 21 +++ .../isoron/uhabits/utils/DateUtilsTest.java | 10 ++ 12 files changed, 324 insertions(+), 16 deletions(-) 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)); + } }