Skip to content

Commit

Permalink
Merge branch 'report-cache'
Browse files Browse the repository at this point in the history
  • Loading branch information
mcndt committed Mar 5, 2022
2 parents 27b7e6f + 9fc7ad6 commit e329db6
Show file tree
Hide file tree
Showing 7 changed files with 349 additions and 32 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [0.8.0]

### ✨ Features

- Toggl reports are now cached for the duration of an Obsidian user session, meaning that reports are only fetched once. Subsequent reports over the same time spans should now load nearly instantly!

## [0.7.3]

### ⚙️ Internal
Expand Down
94 changes: 94 additions & 0 deletions lib/toggl/IntervalCache.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import IntervalCache, { Interval } from './IntervalCache';

let intervalCache: IntervalCache;

describe('parse', () => {
beforeEach(() => {
intervalCache = new IntervalCache();
});

test('Can insert non-overlapping intervals', () => {
intervalCache.insert({ start: 0, end: 2 });
intervalCache.insert({ start: 3, end: 5 });
expect(intervalCache.intervals).toHaveLength(2);
expect(intervalCache.intervals).toContainEqual({ start: 0, end: 2 });
expect(intervalCache.intervals).toContainEqual({ start: 3, end: 5 });
});

test('Can insert overlapping intervals', () => {
intervalCache.insert({ start: 0, end: 2 });
intervalCache.insert({ start: 1, end: 5 });
expect(intervalCache.intervals).toHaveLength(1);
expect(intervalCache.intervals).toContainEqual({ start: 0, end: 5 });
});

test('Returns empty array for fully included intervals', () => {
intervalCache.insert({ start: 0, end: 10 });
expect(intervalCache.check({ start: 2, end: 8 })).toEqual<Interval[]>([]);
expect(intervalCache.check({ start: 0, end: 8 })).toEqual<Interval[]>([]);
expect(intervalCache.check({ start: 2, end: 10 })).toEqual<Interval[]>([]);
expect(intervalCache.check({ start: 0, end: 10 })).toEqual<Interval[]>([]);
});

test('Returns same interval for fully excluded intervals', () => {
intervalCache.insert({ start: 0, end: 10 });
expect(intervalCache.check({ start: -2, end: -1 })).toEqual<Interval[]>([
{ start: -2, end: -1 }
]);
expect(intervalCache.check({ start: 11, end: 12 })).toEqual<Interval[]>([
{ start: 11, end: 12 }
]);
});

test('Returns Interval for partially excluded intervals (right-overlapping)', () => {
intervalCache.insert({ start: 0, end: 10 });
expect(intervalCache.check({ start: 8, end: 14 })).toEqual<Interval[]>([
{
start: 11,
end: 14
}
]);
expect(intervalCache.check({ start: 10, end: 14 })).toEqual<Interval[]>([
{
start: 11,
end: 14
}
]);
});

test('Returns Interval for partially excluded intervals (left-overlapping)', () => {
intervalCache.insert({ start: 0, end: 10 });
expect(intervalCache.check({ start: -5, end: 2 })).toEqual<Interval[]>([
{
start: -5,
end: -1
}
]);
expect(intervalCache.check({ start: -5, end: 0 })).toEqual<Interval[]>([
{
start: -5,
end: -1
}
]);
});

test('Returns Interval for partially excluded intervals (middle-overlapping)', () => {
intervalCache.insert({ start: 0, end: 2 });
intervalCache.insert({ start: 5, end: 7 });

// single interval
let result = intervalCache.check({ start: 1, end: 5 });
expect(result).toContainEqual<Interval>({ start: 3, end: 4 });

// double interval
result = intervalCache.check({ start: 0, end: 9 });
expect(result).toContainEqual<Interval>({ start: 3, end: 4 });
expect(result).toContainEqual<Interval>({ start: 8, end: 9 });

// triple interval
result = intervalCache.check({ start: -2, end: 9 });
expect(result).toContainEqual<Interval>({ start: -2, end: -1 });
expect(result).toContainEqual<Interval>({ start: 3, end: 4 });
expect(result).toContainEqual<Interval>({ start: 8, end: 9 });
});
});
110 changes: 110 additions & 0 deletions lib/toggl/IntervalCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Datastructure that maintains an interval tree of non-overlapping intervals.
// NOTE: this is not time-complexity optimized because it will not contain enough
// data at any point to require better-than-linear scaling.

/** Datastructure that maintains an interval list of non-overlapping intervals.
*
* NOTE: this is not time-complexity optimized because it will not contain
* enough data at any point to require better-than-linear scaling.
*/

export interface Interval {
start: number;
end: number;
}

export default class IntervalCache {
private _intervals: Interval[] = [];

/**
* Inserts a new interval into memory.
*/
public insert(newInterval: Interval): void {
if (this._intervals.length > 0) {
for (let i = 0; i < this._intervals.length; i++) {
const curr = this._intervals[i];
// Checking for overlap:

if (newInterval.end < curr.start) {
// Case A: New interval entirely precedes curr -> insert at position i
this._intervals.splice(i, 0, newInterval);
return;
} else if (doOverlap(curr, newInterval)) {
// Case B: New interval overlaps with curr:
// merge intervals, remove conflicting interval, and insert the new interval.
const mergedInterval = this._mergeIntervals(newInterval, curr);
this._intervals.splice(i, 1);
this.insert(mergedInterval);
return;
}
}
// Case C: the new interval is inserted at the end.
this._intervals.push(newInterval);
} else {
this._intervals.push(newInterval);
}
}

private _mergeIntervals(a: Interval, b: Interval): Interval {
const start = a.start < b.start ? a.start : b.start;
const end = a.end > b.end ? a.end : b.end;
return { start, end };
}

/**
* Checks if the passed interval overlaps with what is already in memory.
* @return empty array if entire interval is contained in cache. Else,
* returns array of missing intervals.
*/
public check(interval: Interval): Interval[] {
const missingIntervals = [interval];
for (const curr of this._intervals) {
const remaining = missingIntervals.pop();
const diff = difference(remaining, curr);
if (diff.length == 0) {
// Case: remaining interval is completely covered.
break;
} else {
missingIntervals.push(...diff);
}
}
return missingIntervals;
}

public get intervals(): Interval[] {
return this._intervals;
}
}

function doOverlap(a: Interval, b: Interval): boolean {
// https://stackoverflow.com/questions/13513932/algorithm-to-detect-overlapping-periods
return a.start <= b.end && b.start <= a.end;
}

/**
* Returns the remainder intervals of a after subtracting b.
* Returns empty array if b entirely overlaps a.
* @param a Interval to subtract from
* @param b Interval to subtract by
*/
function difference(a: Interval, b: Interval): Interval[] {
if (!doOverlap(a, b)) {
// Case 1: a and b are entirely non-overlapping
return [a];
} else if (b.start <= a.start && b.end >= a.end) {
// Case 2: b completely spans a
return [];
} else if (b.start <= a.start && b.end < a.end) {
// Case 3: b left-overlaps a
return [{ start: b.end + 1, end: a.end }];
} else if (b.start >= a.start && b.end >= a.end) {
// Case 4: b right-overlaps a
return [{ start: a.start, end: b.start - 1 }];
} else if (b.start >= a.start && b.end <= a.end) {
return [
{ start: a.start, end: b.start - 1 },
{ start: b.end + 1, end: a.end }
];
}
throw Error('Unaccounted case in differencing two intervals!');
}
81 changes: 80 additions & 1 deletion lib/toggl/TogglCache.ts
Original file line number Diff line number Diff line change
@@ -1 +1,80 @@
export default class TogglCache {}
// Docs for sorted btree: https://www.npmjs.com/package/sorted-btree
import type { Detailed, Report } from 'lib/model/Report';
import type { ISODate } from 'lib/reports/ReportQuery';
import moment from 'moment';
import BTree, { ISortedMap } from 'sorted-btree';
import IntervalCache, { Interval } from './IntervalCache';

/** UNIX-time representation of a date */
type Date = number;

export interface TogglCacheResult {
report: Report<Detailed>;
missing: { from: ISODate; to: ISODate }[];
}

export default class TogglCache {
private intervals = new IntervalCache();
private entries: ISortedMap<Date, Detailed> = new BTree();

/**
* @param start UNIX-time representation of first day of query range.
* @param end UNIX-time representation of last day of query range (inclusive).
* @returns TogglCacheResult with cached time entries and missing intervals from cache.
*/
public get(start: ISODate, end: ISODate): TogglCacheResult {
console.debug(`Fetching ${start}-${end} from interval cache.`);
const start_unix = DateToUnixTime(start);
const end_unix = DateToUnixTime(end);

const data = this.entries
.getRange(start_unix, end_unix + 1000 * 60 * 60 * 24 - 1, true)
.flatMap(([date, detailed]) => detailed);

const total_grand = data.reduce((count, entry) => count + entry.dur, 0);
const report: Report<Detailed> = {
total_grand,
data,
total_count: data.length
};

const missingUnix = this.intervals.check({
start: start_unix,
end: end_unix
});

const missing = missingUnix.map((interval) => {
const from = moment(interval.start * 1000).format('YYYY-MM-DD');
const to = moment(interval.end * 1000).format('YYYY-MM-DD');
return { from, to };
});

return { report, missing };
}

/**
* Caches an array of detailed report entries.
* NOTE: assuems that the entries are a contiguous range with no missing entries!
*/
public put(from: ISODate, to: ISODate, entries: Detailed[]): void {
// Add entries to cache
for (const entry of entries) {
const date = moment(entry.start).unix();
this.entries.set(date, entry, true);
}
// Add new interval to interval cache
const start = DateToUnixTime(from);
const end = DateToUnixTime(to);

this.intervals.insert({ start, end });
console.debug(`Added ${from}-${to} to reports cache.`);
}
}

function DateToUnixTime(dateString: ISODate): Date {
return moment(dateString, 'YYYY-MM-DD')
.set('hour', 0)
.set('minute', 0)
.set('second', 0)
.unix();
}
Loading

0 comments on commit e329db6

Please sign in to comment.