/* * Copyright (C) 2016 Á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.models; import android.support.annotation.*; import org.isoron.uhabits.utils.*; import java.io.*; import java.text.*; import java.util.*; public abstract class ScoreList implements Iterable<Score> { protected final Habit habit; protected ModelObservable observable; /** * Creates a new ScoreList for the given habit. * <p> * The list is populated automatically according to the repetitions that the * habit has. * * @param habit the habit to which the scores belong. */ public ScoreList(Habit habit) { this.habit = habit; observable = new ModelObservable(); } /** * Adds the given scores to the list. * <p> * This method should not be called by the application, since the scores are * computed automatically from the list of repetitions. * * @param scores the scores to add. */ public abstract void add(List<Score> scores); public ModelObservable getObservable() { return observable; } /** * Returns the value of the score for today. * * @return value of today's score */ public int getTodayValue() { return getValue(DateUtils.getStartOfToday()); } /** * Returns the value of the score for a given day. * <p> * If the timestamp given happens before the first repetition of the habit * then returns zero. * * @param timestamp the timestamp of a day * @return score value for that day */ public final int getValue(long timestamp) { compute(timestamp, timestamp); Score s = getComputedByTimestamp(timestamp); if(s == null) throw new IllegalStateException(); return s.getValue(); } /** * Returns the list of scores that fall within the given interval. * <p> * 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<Score> getByInterval(long fromTimestamp, long toTimestamp); /** * Returns the values of the scores that fall inside a certain interval * of time. * <p> * 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<Score> 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<Score> groupBy(DateUtils.TruncateField field) { computeAll(); HashMap<Long, ArrayList<Long>> groups = getGroupedValues(field); List<Score> scores = groupsToAvgScores(groups); Collections.sort(scores, (s1, s2) -> s2.compareNewer(s1)); return scores; } /** * Marks all scores that have timestamp equal to or newer than the given * timestamp as invalid. Any following getValue calls will trigger the * scores to be recomputed. * * @param timestamp the oldest timestamp that should be invalidated */ public abstract void invalidateNewerThan(long timestamp); @Override public Iterator<Score> iterator() { return toList().iterator(); } /** * Returns a Java list of scores, containing one score for each day, from * the first repetition of the habit until today. * <p> * The scores are sorted by decreasing timestamp. The first score * corresponds to today. * * @return list of scores */ public abstract List<Score> toList(); public void writeCSV(Writer out) throws IOException { computeAll(); SimpleDateFormat dateFormat = DateFormats.getCSVDateFormat(); for (Score s : this) { String timestamp = dateFormat.format(s.getTimestamp()); String score = String.format("%.4f", ((float) s.getValue()) / Score.MAX_VALUE); out.write(String.format("%s,%s\n", timestamp, score)); } } /** * Computes and stores one score for each day inside the given interval. * <p> * Scores that have already been computed are skipped, therefore there is no * harm in calling this function more times, or with larger intervals, than * strictly needed. The endpoints of the interval are included. * <p> * This method assumes the list of computed scores has no holes. That is, if * there is a score computed at time t1 and another at time t2, then every * score between t1 and t2 is also computed. * * @param from timestamp of the beginning of the interval * @param to timestamp of the end of the time interval */ protected synchronized void compute(long from, long to) { final long day = DateUtils.millisecondsInOneDay; Score newest = getNewestComputed(); Score oldest = getOldestComputed(); if (newest == null) { Repetition oldestRep = habit.getRepetitions().getOldest(); if (oldestRep != null) from = Math.min(from, oldestRep.getTimestamp()); forceRecompute(from, to, 0); } else { if (oldest == null) throw new IllegalStateException(); forceRecompute(from, oldest.getTimestamp() - day, 0); forceRecompute(newest.getTimestamp() + day, to, newest.getValue()); } } /** * Computes and saves the scores that are missing since the first repetition * of the habit. */ protected void computeAll() { Repetition oldestRep = habit.getRepetitions().getOldest(); if (oldestRep == null) return; long today = DateUtils.getStartOfToday(); compute(oldestRep.getTimestamp(), today); } /** * Returns the score that has the given timestamp, if it has already been * computed. If that score has not been computed yet, returns null. * * @param timestamp the timestamp of the score * @return the score with given timestamp, or null not yet computed. */ @Nullable protected abstract Score getComputedByTimestamp(long timestamp); /** * Returns the most recent score that has already been computed. If no score * has been computed yet, returns null. */ @Nullable protected abstract Score getNewestComputed(); /** * Returns oldest score already computed. If no score has been computed yet, * returns null. */ @Nullable protected abstract Score getOldestComputed(); /** * Computes and stores one score for each day inside the given interval. * <p> * This function does not check if the scores have already been computed. If * they have, then it stores duplicate scores, which is a bad thing. * * @param from timestamp of the beginning of the interval * @param to timestamp of the end of the interval * @param previousValue value of the score on the day immediately before the * interval begins */ private void forceRecompute(long from, long to, int previousValue) { if(from > to) return; final long day = DateUtils.millisecondsInOneDay; final double freq = habit.getFrequency().toDouble(); final int checkmarkValues[] = habit.getCheckmarks().getValues(from, to); List<Score> scores = new LinkedList<>(); for (int i = 0; i < checkmarkValues.length; i++) { int value = checkmarkValues[checkmarkValues.length - i - 1]; previousValue = Score.compute(freq, previousValue, value); scores.add(new Score(from + day * i, previousValue)); } add(scores); } @NonNull private HashMap<Long, ArrayList<Long>> getGroupedValues(DateUtils.TruncateField field) { HashMap<Long, ArrayList<Long>> groups = new HashMap<>(); for (Score s : this) { long groupTimestamp = DateUtils.truncate(field, s.getTimestamp()); if (!groups.containsKey(groupTimestamp)) groups.put(groupTimestamp, new ArrayList<>()); groups.get(groupTimestamp).add((long) s.getValue()); } return groups; } @NonNull private List<Score> groupsToAvgScores(HashMap<Long, ArrayList<Long>> groups) { List<Score> scores = new LinkedList<>(); for (Long timestamp : groups.keySet()) { long meanValue = 0L; ArrayList<Long> groupValues = groups.get(timestamp); for (Long v : groupValues) meanValue += v; meanValue /= groupValues.size(); scores.add(new Score(timestamp, (int) meanValue)); } return scores; } }