From 5d89067f79899a9da9307e4fbc6a01ed0ba35f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Sat, 21 Sep 2019 12:06:06 +0200 Subject: [PATCH 01/32] implemented basic classification visualization --- src/main.js | 1 + src/queries.js | 33 ++++++++------- src/views/ActivityDaily.vue | 30 +++++++++++++- src/visualizations/CategoryTree.vue | 64 +++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 src/visualizations/CategoryTree.vue diff --git a/src/main.js b/src/main.js index fc0330e7..0c1b8bbf 100644 --- a/src/main.js +++ b/src/main.js @@ -38,6 +38,7 @@ Vue.component('aw-timeline', () => import('./visualizations/TimelineSimple.vue') Vue.component('vis-timeline', () => import('./visualizations/VisTimeline.vue')); Vue.component('aw-summaryview', () => import('./components/SummaryView.vue')); +Vue.component("aw-categorytree", () => import("./visualizations/CategoryTree.vue")); //import GCTimeline from './visualizations/GCTimeline.vue'; //Vue.component('GCTimeline', GCTimeline); diff --git a/src/queries.js b/src/queries.js index e1ddf810..23e7413f 100644 --- a/src/queries.js +++ b/src/queries.js @@ -19,33 +19,38 @@ export function summaryQuery(windowbucket, afkbucket, count) { RETURN = {"app_events": app_events, "title_events": title_events};` ); let lines = code.split(";"); - return _.map(lines, (l) => l + ";"); + return _.map(lines, l => l + ";"); } -export function windowQuery(windowbucket, afkbucket, appcount, titlecount, filterAFK) { +export function windowQuery( + windowbucket, + afkbucket, + appcount, + titlecount, + filterAFK, + classes +) { windowbucket = windowbucket.replace('"', '\\"'); afkbucket = afkbucket.replace('"', '\\"'); - let code = ( + let code = `events = flood(query_bucket("${windowbucket}")); not_afk = flood(query_bucket("${afkbucket}")); - not_afk = filter_keyvals(not_afk, "status", ["not-afk"]);` - ) + ( - filterAFK ? 'events = filter_period_intersect(events, not_afk);' : '' - ) + ( - `title_events = merge_events_by_keys(events, ["app", "title"]); - title_events = sort_by_duration(title_events); - app_events = merge_events_by_keys(title_events, ["app"]); - app_events = sort_by_duration(app_events); + not_afk = filter_keyvals(not_afk, "status", ["not-afk"]);` + + (filterAFK ? "events = filter_period_intersect(events, not_afk);" : "") + + ` + events = classify(events, ${JSON.stringify(classes)}); + title_events = sort_by_duration(merge_events_by_keys(events, ["app", "title"])); + app_events = sort_by_duration(merge_events_by_keys(title_events, ["app"])); + cat_events = sort_by_duration(merge_events_by_keys(events, ["$category"])); events = sort_by_timestamp(events); app_chunks = chunk_events_by_key(events, "app"); app_events = limit_events(app_events, ${appcount}); title_events = limit_events(title_events, ${titlecount}); duration = sum_durations(events); - RETURN = {"app_events": app_events, "title_events": title_events, "app_chunks": app_chunks, "duration": duration};` - ); + RETURN = {"app_events": app_events, "title_events": title_events, "cat_events": cat_events, "app_chunks": app_chunks, "duration": duration};`; let lines = code.split(";"); - return _.map(lines, (l) => l + ";"); + return _.map(lines, l => l + ";"); } export function appQuery(appbucket, limit) { diff --git a/src/views/ActivityDaily.vue b/src/views/ActivityDaily.vue index e1df710f..0000d990 100644 --- a/src/views/ActivityDaily.vue +++ b/src/views/ActivityDaily.vue @@ -71,6 +71,20 @@ div | Show more br + div.col-md-4 + h5 Top categories + div(v-if="top_cats") + aw-summary(:fields="top_cats", :namefunc="top_cats_namefunc", :colorfunc="top_cats_colorfunc") + b-button(size="sm", variant="outline-secondary", :disabled="top_cats.length < top_cats_count" @click="top_cats_count += 5; queryTopCategories()") + icon(name="angle-double-down") + | Show more + br + + div.col-md-4 + h5 Category Tree + div(v-if="top_cats") + aw-categorytree(:categories="top_cats") + div(v-show="view == 'window'") b-form-checkbox(v-model="timelineShowAFK") @@ -266,6 +280,11 @@ export default { top_web_urls_namefunc: (e) => e.data.url, top_web_urls_colorfunc: (e) => e.data.domain, + top_cats: [], + top_cats_namefunc: (e) => e.data["$category"], + top_cats_colorfunc: (e) => e.data["$category"], + top_cats_count: 5, + editor_duration: 0, top_editor_count: 5, @@ -385,13 +404,22 @@ export default { queryWindows: async function() { var periods = [this.dateStart + "/" + this.dateEnd]; - var q = query.windowQuery(this.windowBucketId, this.afkBucketId, this.top_apps_count, this.top_windowtitles_count, this.filterAFK); + let classes = [ + ["Work", "[Aa]lacritty"], + ["Work -> Programming", "[Pp]ython"], + ["Work -> Programming -> ActivityWatch", "aw-|[Aa]ctivity[Ww]atch"], + ["Comms -> IM", "Messenger"], + ["Comms -> Email", "Gmail"], + ]; + var q = query.windowQuery(this.windowBucketId, this.afkBucketId, this.top_apps_count, this.top_windowtitles_count, this.filterAFK, classes); let data = await this.$aw.query(periods, q).catch(this.errorHandler); data = data[0]; this.top_apps = data["app_events"]; this.top_windowtitles = data["title_events"]; this.app_chunks = data["app_chunks"]; this.duration = data["duration"]; + this.top_cats = data["cat_events"]; + console.log(JSON.parse(JSON.stringify(this.top_cats))); }, queryBrowserDomains: async function() { diff --git a/src/visualizations/CategoryTree.vue b/src/visualizations/CategoryTree.vue new file mode 100644 index 00000000..0d7d1878 --- /dev/null +++ b/src/visualizations/CategoryTree.vue @@ -0,0 +1,64 @@ + + + + + From 9536e638700431c162a7df76ebf178534e6e2d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Sat, 21 Sep 2019 12:49:10 +0200 Subject: [PATCH 02/32] added prettierrc and ran it through some files --- .prettierrc.yml | 4 ++ src/main.js | 9 ++-- src/queries.js | 110 ++++++++++++++++++++++++------------------------ 3 files changed, 65 insertions(+), 58 deletions(-) create mode 100644 .prettierrc.yml diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 00000000..9e1db335 --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1,4 @@ +singleQuote: true +trailingComma: 'es5' +tabWidth: 2 +printWidth: 100 diff --git a/src/main.js b/src/main.js index 0c1b8bbf..6a0ba448 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,4 @@ -import "@babel/polyfill"; +import '@babel/polyfill'; import Vue from 'vue'; @@ -9,7 +9,7 @@ import 'bootstrap-vue/dist/bootstrap-vue.css'; Vue.use(BootstrapVue); // Setup default settings -if (!("startOfDay" in localStorage)) { +if (!('startOfDay' in localStorage)) { localStorage.startOfDay = '04:00'; } @@ -36,9 +36,10 @@ Vue.component('aw-sunburst', () => import('./visualizations/Sunburst.vue')); Vue.component('aw-timeline-inspect', () => import('./visualizations/TimelineInspect.vue')); Vue.component('aw-timeline', () => import('./visualizations/TimelineSimple.vue')); Vue.component('vis-timeline', () => import('./visualizations/VisTimeline.vue')); +Vue.component('aw-categorytree', () => import('./visualizations/CategoryTree.vue')); Vue.component('aw-summaryview', () => import('./components/SummaryView.vue')); -Vue.component("aw-categorytree", () => import("./visualizations/CategoryTree.vue")); +Vue.component('aw-categorytree', () => import('./visualizations/CategoryTree.vue')); //import GCTimeline from './visualizations/GCTimeline.vue'; //Vue.component('GCTimeline', GCTimeline); @@ -53,5 +54,5 @@ import App from './App'; new Vue({ el: '#app', router: router, - render: h => h(App) + render: h => h(App), }); diff --git a/src/queries.js b/src/queries.js index 23e7413f..8e63b234 100644 --- a/src/queries.js +++ b/src/queries.js @@ -5,8 +5,7 @@ import _ from 'lodash'; export function summaryQuery(windowbucket, afkbucket, count) { windowbucket = windowbucket.replace('"', '\\"'); afkbucket = afkbucket.replace('"', '\\"'); - let code = ( - `events = flood(query_bucket("${windowbucket}")); + let code = `events = flood(query_bucket("${windowbucket}")); not_afk = flood(query_bucket("${afkbucket}")); not_afk = filter_keyvals(not_afk, "status", ["not-afk"]); events = filter_period_intersect(events, not_afk); @@ -16,27 +15,19 @@ export function summaryQuery(windowbucket, afkbucket, count) { app_events = sort_by_duration(app_events); app_events = limit_events(app_events, ${count}); title_events = limit_events(title_events, ${count}); - RETURN = {"app_events": app_events, "title_events": title_events};` - ); - let lines = code.split(";"); - return _.map(lines, l => l + ";"); + RETURN = {"app_events": app_events, "title_events": title_events};`; + let lines = code.split(';'); + return _.map(lines, l => l + ';'); } -export function windowQuery( - windowbucket, - afkbucket, - appcount, - titlecount, - filterAFK, - classes -) { +export function windowQuery(windowbucket, afkbucket, appcount, titlecount, filterAFK, classes) { windowbucket = windowbucket.replace('"', '\\"'); afkbucket = afkbucket.replace('"', '\\"'); let code = `events = flood(query_bucket("${windowbucket}")); not_afk = flood(query_bucket("${afkbucket}")); not_afk = filter_keyvals(not_afk, "status", ["not-afk"]);` + - (filterAFK ? "events = filter_period_intersect(events, not_afk);" : "") + + (filterAFK ? 'events = filter_period_intersect(events, not_afk);' : '') + ` events = classify(events, ${JSON.stringify(classes)}); title_events = sort_by_duration(merge_events_by_keys(events, ["app", "title"])); @@ -49,70 +40,84 @@ export function windowQuery( title_events = limit_events(title_events, ${titlecount}); duration = sum_durations(events); RETURN = {"app_events": app_events, "title_events": title_events, "cat_events": cat_events, "app_chunks": app_chunks, "duration": duration};`; - let lines = code.split(";"); - return _.map(lines, l => l + ";"); + let lines = code.split(';'); + return _.map(lines, l => l + ';'); } export function appQuery(appbucket, limit) { appbucket = appbucket.replace('"', '\\"'); limit = limit || 5; - let code = ( - `events = query_bucket("${appbucket}");` - ) + ( + let code = + `events = query_bucket("${appbucket}");` + `events = merge_events_by_keys(events, ["app"]); events = sort_by_duration(events); events = limit_events(events, ${limit}); total_duration = sum_durations(events); - RETURN = {"events": events, "total_duration": total_duration};` - ); - let lines = code.split(";"); - return _.map(lines, (l) => l + ";"); + RETURN = {"events": events, "total_duration": total_duration};`; + let lines = code.split(';'); + return _.map(lines, l => l + ';'); } const appnames = { - "chrome": ["Google-chrome", "chrome.exe", "Chromium", "Google Chrome", "Chromium-browser", "Chromium-browser-chromium", "Google-chrome-beta", "Google-chrome-unstable"], - "firefox": ["Firefox", "Firefox.exe", "firefox", "firefox.exe", "Firefox Developer Edition", "Firefox Beta", "Nightly"], - "opera": ["opera.exe", "Opera"], - "brave": ["brave.exe"], + chrome: [ + 'Google-chrome', + 'chrome.exe', + 'Chromium', + 'Google Chrome', + 'Chromium-browser', + 'Chromium-browser-chromium', + 'Google-chrome-beta', + 'Google-chrome-unstable', + ], + firefox: [ + 'Firefox', + 'Firefox.exe', + 'firefox', + 'firefox.exe', + 'Firefox Developer Edition', + 'Firefox Beta', + 'Nightly', + ], + opera: ['opera.exe', 'Opera'], + brave: ['brave.exe'], }; export function browserSummaryQuery(browserbuckets, windowbucket, afkbucket, limit, filterAFK) { // Escape `"` - browserbuckets = _.map(browserbuckets, (b) => b.replace('"', '\\"')); + browserbuckets = _.map(browserbuckets, b => b.replace('"', '\\"')); windowbucket = windowbucket.replace('"', '\\"'); afkbucket = afkbucket.replace('"', '\\"'); limit = limit || 5; // If multiple browser buckets were found - let code = ( + let code = `events = []; - window = flood(query_bucket("${windowbucket}"));` - ) + (filterAFK ? - `not_afk = flood(query_bucket("${afkbucket}")); - not_afk = filter_keyvals(not_afk, "status", ["not-afk"]);` : '' - ); + window = flood(query_bucket("${windowbucket}"));` + + (filterAFK + ? `not_afk = flood(query_bucket("${afkbucket}")); + not_afk = filter_keyvals(not_afk, "status", ["not-afk"]);` + : ''); - _.each(["chrome", "firefox", "opera", "brave"], (browserName) => { - let bucketId = _.filter(browserbuckets, (buckets) => buckets.indexOf(browserName) !== -1)[0]; - if(bucketId === undefined) { + _.each(['chrome', 'firefox', 'opera', 'brave'], browserName => { + let bucketId = _.filter(browserbuckets, buckets => buckets.indexOf(browserName) !== -1)[0]; + if (bucketId === undefined) { // Skip browser if specific bucket not available return; } let appnames_str = JSON.stringify(appnames[browserName]); - code += ( + code += `events_${browserName} = flood(query_bucket("${bucketId}")); - window_${browserName} = filter_keyvals(window, "app", ${appnames_str});` - ) + ( - filterAFK ? `window_${browserName} = filter_period_intersect(window_${browserName}, not_afk);` : '' - ) + ( + window_${browserName} = filter_keyvals(window, "app", ${appnames_str});` + + (filterAFK + ? `window_${browserName} = filter_period_intersect(window_${browserName}, not_afk);` + : '') + `events_${browserName} = filter_period_intersect(events_${browserName}, window_${browserName}); events_${browserName} = split_url_events(events_${browserName}); - events = sort_by_timestamp(concat(events, events_${browserName}));` - ); - }) + events = sort_by_timestamp(concat(events, events_${browserName}));`; + }); - let lines = code.split(";"); - let query = _.map(lines, (l) => l + ";"); + let lines = code.split(';'); + let query = _.map(lines, l => l + ';'); return query.concat([ 'urls = merge_events_by_keys(events, ["url"]);', 'urls = sort_by_duration(urls);', @@ -139,7 +144,7 @@ export function editorActivityQuery(editorbucket, limit) { 'projects = sort_by_duration(merge_events_by_keys(events, ["project"]));', 'projects = limit_events(projects, ' + limit + ');', 'duration = sum_durations(events);', - 'RETURN = {"files": files, "languages": languages, "projects": projects, "duration": duration};' + 'RETURN = {"files": files, "languages": languages, "projects": projects, "duration": duration};', ]; } @@ -149,16 +154,13 @@ export function dailyActivityQuery(afkbucket) { 'afkbucket = "' + afkbucket + '";', 'not_afk = flood(query_bucket(afkbucket));', 'not_afk = merge_events_by_keys(not_afk, ["status"]);', - 'RETURN = not_afk;' + 'RETURN = not_afk;', ]; } export function dailyActivityQueryAndroid(androidbucket) { androidbucket = androidbucket.replace('"', '\\"'); - return [ - `events = query_bucket("${androidbucket}");`, - 'RETURN = sum_durations(events);' - ]; + return [`events = query_bucket("${androidbucket}");`, 'RETURN = sum_durations(events);']; } export default { From a4952cea962fb63f8d709c7e3bb3198c2384cf59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Sat, 21 Sep 2019 12:49:59 +0200 Subject: [PATCH 03/32] fixed category hierarchy bug --- src/main.js | 1 - src/views/ActivityDaily.vue | 2 +- src/visualizations/CategoryTree.vue | 41 ++++++++++++++++++++--------- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/main.js b/src/main.js index 6a0ba448..9ddec1e0 100644 --- a/src/main.js +++ b/src/main.js @@ -36,7 +36,6 @@ Vue.component('aw-sunburst', () => import('./visualizations/Sunburst.vue')); Vue.component('aw-timeline-inspect', () => import('./visualizations/TimelineInspect.vue')); Vue.component('aw-timeline', () => import('./visualizations/TimelineSimple.vue')); Vue.component('vis-timeline', () => import('./visualizations/VisTimeline.vue')); -Vue.component('aw-categorytree', () => import('./visualizations/CategoryTree.vue')); Vue.component('aw-summaryview', () => import('./components/SummaryView.vue')); Vue.component('aw-categorytree', () => import('./visualizations/CategoryTree.vue')); diff --git a/src/views/ActivityDaily.vue b/src/views/ActivityDaily.vue index 0000d990..e4576c07 100644 --- a/src/views/ActivityDaily.vue +++ b/src/views/ActivityDaily.vue @@ -83,7 +83,7 @@ div div.col-md-4 h5 Category Tree div(v-if="top_cats") - aw-categorytree(:categories="top_cats") + aw-categorytree(:events="top_cats") div(v-show="view == 'window'") diff --git a/src/visualizations/CategoryTree.vue b/src/visualizations/CategoryTree.vue index 0d7d1878..b628b16a 100644 --- a/src/visualizations/CategoryTree.vue +++ b/src/visualizations/CategoryTree.vue @@ -32,33 +32,50 @@ function get_parent_cats(cat) { export default { name: "aw-categorytree", - props: ['categories'], + props: ['events'], computed: { category_hierarchy: function() { - let events = JSON.parse(JSON.stringify(this.categories)); + let events = JSON.parse(JSON.stringify(this.events)); // Compute hierarchy for all events _.map(events, e => e.data["$category_hierarchy"] = get_parent_cats(e.data["$category"])); // Collect all categories at all depths - let categories = _.union(_.flatten(_.map(events, (e) => e.data["$category_hierarchy"]))); - - let cat_time = _.map(categories, (c) => { + let all_cat_names = _.union(_.flatten(_.map(events, (e) => e.data["$category_hierarchy"]))); + let cats = _.map(all_cat_names, (c) => { + let depth = count_substr(c, "->"); return { name: c, + parent: c.split("->").map(s => s.trim()).slice(0, depth).join(" -> ") || null, subname: c.split("->").slice(-1).pop(), - depth: count_substr(c, "->"), + depth: depth, duration: _.sumBy(_.filter(events, e => e.data["$category_hierarchy"].includes(c)), e => e.duration) } }); - console.log(cat_time); - return cat_time; + function _get_child_cats(cat, all_cats) { + return _.filter(all_cats, c => c.parent == cat.name) + } + + function _assign_children(parent, all_cats) { + let child_cats = _get_child_cats(parent, all_cats); + // Recurse + _.map(child_cats, c => _assign_children(c, all_cats)); + parent.children = _.sortBy(child_cats, cc => -cc.duration); + } + + let cats_with_depth0 = _.sortBy(_.filter(cats, c => c.depth == 0), c => -c.duration); + _.map(cats_with_depth0, c => _assign_children(c, cats)); + + // Flattens the category hierarchy + function _flatten_hierarchy(c) { + if(!c.children) return []; + return _.flattenDeep([c, _.map(c.children, cc => _flatten_hierarchy(cc))]); + } + cats = _.flatten(_.map(cats_with_depth0, c => _flatten_hierarchy(c))); + console.log(cats); + return cats; } }, - mounted: function() { - }, - watch: { - } } From 05f89db2b8bda46b56b555b44481bd9b66ed41d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Sat, 21 Sep 2019 13:06:49 +0200 Subject: [PATCH 04/32] comments and changed default classes --- src/views/ActivityDaily.vue | 8 ++++++-- src/visualizations/CategoryTree.vue | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/views/ActivityDaily.vue b/src/views/ActivityDaily.vue index e4576c07..f81bab44 100644 --- a/src/views/ActivityDaily.vue +++ b/src/views/ActivityDaily.vue @@ -405,8 +405,12 @@ export default { queryWindows: async function() { var periods = [this.dateStart + "/" + this.dateEnd]; let classes = [ - ["Work", "[Aa]lacritty"], - ["Work -> Programming", "[Pp]ython"], + ["Media -> Games", "RimWorld"], + ["Media -> Video", "YouTube"], + ["Media -> Social Media", "reddit|Facebook|Twitter"], + ["Remote", "Shadow"], + ["Work", "[Aa]lacritty|Google Docs"], + ["Work -> Programming", "[Pp]ython|GitHub"], ["Work -> Programming -> ActivityWatch", "aw-|[Aa]ctivity[Ww]atch"], ["Comms -> IM", "Messenger"], ["Comms -> Email", "Gmail"], diff --git a/src/visualizations/CategoryTree.vue b/src/visualizations/CategoryTree.vue index b628b16a..1f7dc748 100644 --- a/src/visualizations/CategoryTree.vue +++ b/src/visualizations/CategoryTree.vue @@ -74,6 +74,7 @@ export default { } cats = _.flatten(_.map(cats_with_depth0, c => _flatten_hierarchy(c))); console.log(cats); + // TODO: If a category has children, but also activity attributed directly to the parent that does not belong to a child, then create a "Other" child containing the activity. return cats; } }, From 7effc781b5e3390105e44c072401a411c7482c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Tue, 1 Oct 2019 15:03:14 +0200 Subject: [PATCH 05/32] massive refactor, split up ActivityDaily into subroutes/views --- src/components/Header.vue | 4 +- src/queries.js | 4 +- src/route.js | 67 ++++-- src/util/classes.js | 14 ++ src/util/time.js | 70 +++--- src/views/ActivityAndroid.vue | 6 +- src/views/ActivityDaily.vue | 309 +------------------------ src/views/ActivityDailyBrowser.vue | 141 +++++++++++ src/views/ActivityDailyEditor.vue | 134 +++++++++++ src/views/ActivityDailySummary.vue | 169 ++++++++++++++ src/views/ActivityDailyWindow.vue | 88 +++++++ src/views/ActivitySummary.vue | 6 +- src/visualizations/PeriodUsage.vue | 4 +- src/visualizations/TimelineInspect.vue | 17 +- src/visualizations/periodusage.js | 145 ++++++------ src/visualizations/timeline.js | 207 +++++++++-------- 16 files changed, 851 insertions(+), 534 deletions(-) create mode 100644 src/util/classes.js create mode 100644 src/views/ActivityDailyBrowser.vue create mode 100644 src/views/ActivityDailyEditor.vue create mode 100644 src/views/ActivityDailySummary.vue create mode 100644 src/views/ActivityDailyWindow.vue diff --git a/src/components/Header.vue b/src/components/Header.vue index 2e90f7a2..f788aff2 100644 --- a/src/components/Header.vue +++ b/src/components/Header.vue @@ -27,12 +27,12 @@ div.aw-navbar | No activity reports available br small Make sure you have both an AFK and window watcher running - b-dropdown-item(v-for="view in activityViewsDaily", :key="view.name", :to="view.pathUrl + '/' + view.hostname") + b-dropdown-item(v-for="view in activityViewsDaily", :key="view.name", :to="view.pathUrl + '/' + view.hostname + '/summary'") icon(:name="view.icon") | {{ view.name }} // If only a single view (the default) is available - b-nav-item(v-if="activityViewsSummary.length === 1", v-for="view in activityViewsSummary", :key="view.name + '_summary'", :to="view.pathUrl + '/' + view.hostname") + b-nav-item(v-if="activityViewsSummary.length === 1", v-for="view in activityViewsSummary", :key="view.name + '_summary'", :to="view.pathUrl + '/' + view.hostname + '/summary'") div.px-2.px-lg-1 icon(name="calendar-week") | Summary diff --git a/src/queries.js b/src/queries.js index c0337737..d1ff4456 100644 --- a/src/queries.js +++ b/src/queries.js @@ -124,10 +124,10 @@ export function browserSummaryQuery(browserbuckets, windowbucket, afkbucket, lim 'urls = sort_by_duration(urls);', 'urls = limit_events(urls, ' + limit + ');', 'domains = split_url_events(events);', - 'domains = merge_events_by_keys(domains, ["domain"]);', + 'domains = merge_events_by_keys(domains, ["$domain"]);', 'domains = sort_by_duration(domains);', 'domains = limit_events(domains, ' + limit + ');', - 'chunks = chunk_events_by_key(events, "domain");', + 'chunks = chunk_events_by_key(events, "$domain");', 'duration = sum_durations(events);', 'RETURN = {"domains": domains, "urls": urls, "chunks": chunks, "duration": duration};', ]); diff --git a/src/route.js b/src/route.js index 42778fa9..94ee70ef 100644 --- a/src/route.js +++ b/src/route.js @@ -2,7 +2,14 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; const Home = () => import('./views/Home.vue'); -const Activity = () => import('./views/ActivityDaily.vue'); + +// Daily activity views for desktop +const ActivityDaily = () => import('./views/ActivityDaily.vue'); +const ActivityDailySummary = () => import('./views/ActivityDailySummary.vue'); +const ActivityDailyWindow = () => import('./views/ActivityDailyWindow.vue'); +const ActivityDailyBrowser = () => import('./views/ActivityDailyBrowser.vue'); +const ActivityDailyEditor = () => import('./views/ActivityDailyEditor.vue'); + const ActivitySummary = () => import('./views/ActivitySummary.vue'); const ActivityAndroid = () => import('./views/ActivityAndroid.vue'); const Buckets = () => import('./views/Buckets.vue'); @@ -17,21 +24,49 @@ Vue.use(VueRouter); var router = new VueRouter({ routes: [ - { path: '/', component: Home }, - { path: '/activity/daily/:host', component: Activity}, - { path: '/activity/daily/:host/:date', component: Activity }, - { path: '/activity/summary/:host', component: ActivitySummary }, - { path: '/activity/summary/:host/:date', component: ActivitySummary }, - { path: '/activity/android/:host', component: ActivityAndroid }, - { path: '/activity/android/:host/:date', component: ActivityAndroid }, - { path: '/buckets', component: Buckets }, - { path: '/buckets/:id', component: Bucket }, - { path: '/timeline', component: Timeline }, - { path: '/query', component: QueryExplorer }, - { path: '/log', component: Log }, - { path: '/settings', component: Settings }, - { path: '/stopwatch', component: Stopwatch }, - ] + { path: '/', component: Home }, + { + path: '/activity/daily/:host/:date?', + component: ActivityDaily, + name: 'activity-daily', + props: true, + children: [ + { + path: 'summary', + name: 'activity-daily-summary', + component: ActivityDailySummary, + props: true, + }, + { + path: 'window', + name: 'activity-daily-window', + component: ActivityDailyWindow, + props: true, + }, + { + path: 'browser', + name: 'activity-daily-browser', + component: ActivityDailyBrowser, + props: true, + }, + { + path: 'editor', + name: 'activity-daily-editor', + component: ActivityDailyEditor, + props: true, + }, + ], + }, + { path: '/activity/summary/:host/:date?', component: ActivitySummary }, + { path: '/activity/android/:host/:date?', component: ActivityAndroid }, + { path: '/buckets', component: Buckets }, + { path: '/buckets/:id', component: Bucket }, + { path: '/timeline', component: Timeline }, + { path: '/query', component: QueryExplorer }, + { path: '/log', component: Log }, + { path: '/settings', component: Settings }, + { path: '/stopwatch', component: Stopwatch }, + ], }); export default router; diff --git a/src/util/classes.js b/src/util/classes.js new file mode 100644 index 00000000..f44ae8aa --- /dev/null +++ b/src/util/classes.js @@ -0,0 +1,14 @@ +export function loadClasses() { + // TODO: Actually load from settings somewhere + return [ + ['Media -> Games', 'RimWorld'], + ['Media -> Video', 'YouTube'], + ['Media -> Social Media', 'reddit|Facebook|Twitter'], + ['Remote', 'Shadow'], + ['Work', '[Aa]lacritty|Google Docs'], + ['Work -> Programming', '[Pp]ython|GitHub'], + ['Work -> Programming -> ActivityWatch', 'aw-|[Aa]ctivity[Ww]atch'], + ['Comms -> IM', 'Messenger'], + ['Comms -> Email', 'Gmail'], + ]; +} diff --git a/src/util/time.js b/src/util/time.js index 6ff97a4b..f198c408 100644 --- a/src/util/time.js +++ b/src/util/time.js @@ -1,37 +1,45 @@ import moment from 'moment'; export default { - seconds_to_duration(seconds) { - var result = ""; - var hrs = Math.floor(seconds/60/60); - var min = Math.floor(seconds/60%60); - var sec = Math.floor(seconds%60); - if (hrs != 0) - result += hrs + "h"; - if (hrs != 0 || min != 0) - result += min + "m"; - if (hrs == 0) - result += sec + "s"; - return result; - }, + seconds_to_duration(seconds) { + var result = ''; + var hrs = Math.floor(seconds / 60 / 60); + var min = Math.floor((seconds / 60) % 60); + var sec = Math.floor(seconds % 60); + if (hrs != 0) result += hrs + 'h'; + if (hrs != 0 || min != 0) result += min + 'm'; + if (hrs == 0) result += sec + 's'; + return result; + }, - get_day_start(datestr) { - // Get start time of date - var datestart = moment(datestr); - datestart.set('hour', 0); - datestart.set('minute', 0); - datestart.set('second', 0); - datestart.set('millisecond', 0); - return datestart; - }, + get_day_start_with_offset(dateParam) { + var dateMoment = dateParam ? moment(dateParam) : moment().startOf('day'); + var start_of_day = localStorage.startOfDay; + var start_of_day_hours = parseInt(start_of_day.split(':')[0]); + var start_of_day_minutes = parseInt(start_of_day.split(':')[1]); + return dateMoment + .hour(start_of_day_hours) + .minute(start_of_day_minutes) + .format(); + }, - get_prev_day(datestr){ - var newdate = moment(datestr).add(-1, 'days'); - return newdate; - }, + get_day_start(datestr) { + // Get start time of date + var datestart = moment(datestr); + datestart.set('hour', 0); + datestart.set('minute', 0); + datestart.set('second', 0); + datestart.set('millisecond', 0); + return datestart; + }, - get_next_day(datestr){ - var newdate = moment(datestr).add(1, 'days'); - return newdate; - }, -} + get_prev_day(datestr) { + var newdate = moment(datestr).add(-1, 'days'); + return newdate; + }, + + get_next_day(datestr) { + var newdate = moment(datestr).add(1, 'days'); + return newdate; + }, +}; diff --git a/src/views/ActivityAndroid.vue b/src/views/ActivityAndroid.vue index f39aef53..69b5929d 100644 --- a/src/views/ActivityAndroid.vue +++ b/src/views/ActivityAndroid.vue @@ -93,11 +93,7 @@ export default { computed: { host: function() { return this.$route.params.host }, - date: function() { - var dateParam = this.$route.params.date; - var dateMoment = dateParam ? moment(dateParam) : moment(); - return dateMoment.startOf('day').format(); - }, + date: function() { return time.get_day_start_with_offset(this.$route.params.date) }, dateStart: function() { return this.date }, dateEnd: function() { return moment(this.date).add(1, 'days').format() }, dateShort: function() { return moment(this.date).format("YYYY-MM-DD") }, diff --git a/src/views/ActivityDaily.vue b/src/views/ActivityDaily.vue index f81bab44..00b2b7f8 100644 --- a/src/views/ActivityDaily.vue +++ b/src/views/ActivityDaily.vue @@ -31,154 +31,24 @@ div span.d-none.d-md-inline | Refresh - aw-periodusage(:periodusage_arr="daily_activity", :link_prefix="link_prefix" dateformat="YYYY-MM-DD") + aw-periodusage(:periodusage_arr="daily_activity", :link_prefix="link_prefix") ul.nav.nav-tabs.my-3 li.nav-item.aw-nav-item - a.nav-link.aw-nav-link(@click="view = 'summary'" :class="{ active: view=='summary' }") + router-link.nav-link.aw-nav-link(:to="{ name: 'activity-daily-summary', params: $route.params }" :class="{ active: $route.path.includes('summary') }") h5 Summary li.nav-item.aw-nav-item - a.nav-link.aw-nav-link(@click="view = 'window'" :class="{ active: view=='window' }") + router-link.nav-link.aw-nav-link(:to="{ name: 'activity-daily-window', params: $route.params }" :class="{ active: $route.path.includes('window') }") h5 Window li.nav-item.aw-nav-item - a.nav-link.aw-nav-link(@click="view = 'browser'" :class="{ active: view=='browser' }") + router-link.nav-link.aw-nav-link(:to="{ name: 'activity-daily-browser', params: $route.params }" :class="{ active: $route.path.includes('browser') }") h5.active-h5 Browser li.nav-item.aw-nav-item - a.nav-link.aw-nav-link(@click="view = 'editor'" :class="{ active: view=='editor' }") + router-link.nav-link.aw-nav-link(:to="{ name: 'activity-daily-editor', params: $route.params }" :class="{ active: $route.path.includes('editor') }") h5 Editor - div.row(v-show="view == 'summary'") - div.col-md-4 - h5 Top Applications - aw-summary(:fields="top_apps", :namefunc="top_apps_namefunc", :colorfunc="top_apps_colorfunc") - b-button(size="sm", variant="outline-secondary", :disabled="top_apps.length < top_apps_count", @click="top_apps_count += 5; queryWindows()") - icon(name="angle-double-down") - | Show more - - div.col-md-4 - h5 Top Window Titles - aw-summary(:fields="top_windowtitles", :namefunc="top_windowtitles_namefunc", :colorfunc="top_windowtitles_colorfunc") - b-button(size="sm", variant="outline-secondary", :disabled="top_windowtitles.length < top_windowtitles_count", @click="top_windowtitles_count += 5; queryWindows()") - icon(name="angle-double-down") - | Show more - - div.col-md-4 - h5 Top Browser Domains - div(v-if="browserBuckets") - aw-summary(:fields="top_web_domains", :namefunc="top_web_domains_namefunc", :colorfunc="top_web_domains_colorfunc") - b-button(size="sm", variant="outline-secondary", :disabled="top_web_domains.length < top_web_count" @click="top_web_count += 5; queryBrowserDomains()") - icon(name="angle-double-down") - | Show more - br - - div.col-md-4 - h5 Top categories - div(v-if="top_cats") - aw-summary(:fields="top_cats", :namefunc="top_cats_namefunc", :colorfunc="top_cats_colorfunc") - b-button(size="sm", variant="outline-secondary", :disabled="top_cats.length < top_cats_count" @click="top_cats_count += 5; queryTopCategories()") - icon(name="angle-double-down") - | Show more - br - - div.col-md-4 - h5 Category Tree - div(v-if="top_cats") - aw-categorytree(:events="top_cats") - - div(v-show="view == 'window'") - - b-form-checkbox(v-model="timelineShowAFK") - | Show AFK time - - aw-timeline-inspect(:chunks="app_chunks", :total_duration='duration', :show_afk='timelineShowAFK', :chunkfunc='app_chunkfunc', :eventfunc='app_eventfunc') - - hr - - aw-sunburst(:date="date", :afkBucketId="afkBucketId", :windowBucketId="windowBucketId") - - div(v-show="view == 'browser'") - b-input-group(size="sm") - b-input-group-prepend - span.input-group-text - | Bucket - b-dropdown(:text="(browserBucketSelected !== 'all' && browserBucketSelected) || 'Select browser watcher bucket'", size="sm", variant="outline-secondary") - b-dropdown-header - | Browser bucket to use - b-dropdown-item(v-if="browserBuckets.length <= 0", name="b", disabled) - | No browser buckets available - br - small Make sure you have an browser extension installed - b-dropdown-item-button(v-if="browserBuckets.length > 1", @click="browserBucketSelected = 'all'") - | All - b-dropdown-item-button(v-for="browserBucket in browserBuckets", :key="browserBucket", @click="browserBucketSelected = browserBucket") - | {{ browserBucket }} - br - - h6 Active browser time: {{ readableWebDuration }} - - div.row - div.col-md-6 - h5 Top Browser Domains - - div(v-if="browserBuckets") - aw-summary(:fields="top_web_domains", :namefunc="top_web_domains_namefunc", :colorfunc="top_web_domains_colorfunc") - - div.col-md-6 - h5 Top Browser URLs - - div(v-if="browserBuckets") - aw-summary(:fields="top_web_urls", :namefunc="top_web_urls_namefunc", :colorfunc="top_web_urls_colorfunc") - - b-button(size="sm", variant="outline-secondary", :disabled="top_web_urls.length < top_web_count && top_web_domains.length < top_web_count" @click="top_web_count += 5; queryBrowserDomains()") - icon(name="angle-double-down") - | Show more - - hr - - b-form-checkbox(v-model="timelineShowAFK") - | Show AFK time - - br - - aw-timeline-inspect(:chunks="web_chunks", :total_duration='duration', :show_afk='timelineShowAFK', :chunkfunc='web_chunkfunc', :eventfunc='web_eventfunc') - - div(v-show="view == 'editor'") - b-input-group(size="sm") - b-input-group-prepend - span.input-group-text - | Bucket - b-dropdown(:text="editorBucketId || 'Select editor watcher bucket'", size="sm", variant="outline-secondary") - b-dropdown-header - | Editor bucket to use - b-dropdown-item(v-if="editorBuckets.length <= 0", name="b", disabled) - | No editor buckets available - br - small Make sure you have an editor watcher installed to use this feature - b-dropdown-item-button(v-for="editorBucket in editorBuckets", :key="editorBucket", @click="editorBucketId = editorBucket") - | {{ editorBucket }} - - br - - h6 Active editor time: {{ readableEditorDuration }} - - div(v-if="editorBucketId") - div.row(style="padding-top: 0.5em;") - div.col-md-4 - h5 Top file activity - aw-summary(:fields="top_editor_files", :namefunc="top_editor_files_namefunc", :colorfunc="top_editor_files_colorfunc") - - div.col-md-4 - h5 Top language activity - aw-summary(:fields="top_editor_languages", :namefunc="top_editor_languages_namefunc", :colorfunc="top_editor_languages_colorfunc") - - div.col-md-4 - h5 Top project activity - aw-summary(:fields="top_editor_projects", :namefunc="top_editor_projects_namefunc", :colorfunc="top_editor_projects_colorfunc") - - b-button(size="sm", variant="outline-secondary", @click="top_editor_count += 5; queryEditorActivity()") - icon(name="angle-double-down") - | Show more - + div + router-view hr @@ -188,8 +58,6 @@ div div b-form-checkbox(v-model="filterAFK") | Filter away AFK time - - - diff --git a/src/views/activity/daily/ActivityDailySummary.vue b/src/views/activity/daily/ActivityDailySummary.vue index 3fdfc92c..6497ab72 100644 --- a/src/views/activity/daily/ActivityDailySummary.vue +++ b/src/views/activity/daily/ActivityDailySummary.vue @@ -41,7 +41,7 @@ div diff --git a/src/views/Settings.vue b/src/views/Settings.vue index 5e1df969..9a43a52b 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -1,4 +1,3 @@ - diff --git a/src/views/activity/daily/ActivityDailyWindow.vue b/src/views/activity/daily/ActivityDailyWindow.vue index e26bd0d3..978ea4a4 100644 --- a/src/views/activity/daily/ActivityDailyWindow.vue +++ b/src/views/activity/daily/ActivityDailyWindow.vue @@ -3,17 +3,15 @@ div b-form-checkbox(v-model="timelineShowAFK") | Show AFK time - aw-timeline-inspect(:chunks="app_chunks", :show_afk='timelineShowAFK', :chunkfunc='app_chunkfunc', :eventfunc='app_eventfunc') + aw-timeline-inspect(:chunks="app_chunks", :show_afk='timelineShowAFK', :chunkfunc='e => e.data.app', :eventfunc='e => e.data.title') hr - aw-sunburst(:date="date", :afkBucketId="afkBucketId", :windowBucketId="windowBucketId") - + aw-sunburst(:date="date", :afkBucketId="bucket_id_afk", :windowBucketId="bucket_id_window") diff --git a/src/visualizations/sunburst.js b/src/visualizations/sunburst-clock.js similarity index 100% rename from src/visualizations/sunburst.js rename to src/visualizations/sunburst-clock.js From e746643d5322c2ef2a7824836dc804fa37f6e7e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Mon, 7 Oct 2019 17:02:13 +0200 Subject: [PATCH 18/32] fixed categorization stuff to be up-to-date with latest changes to aw-server-rust and aw-server-python --- jest.config.js | 4 + package-lock.json | 71 ++++++++++++ package.json | 1 + src/queries.js | 2 +- src/util/classes.test.js | 12 +-- src/util/classes.ts | 101 ++++++++++-------- src/views/CategoryEditTree.vue | 4 +- .../activity/daily/ActivityDailySummary.vue | 2 +- src/visualizations/CategoryTree.vue | 38 +++---- 9 files changed, 155 insertions(+), 80 deletions(-) create mode 100644 jest.config.js diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..91a2d2c0 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d5724bf0..a0d96dba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2743,6 +2743,15 @@ "node-releases": "^1.1.25" } }, + "bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "requires": { + "fast-json-stable-stringify": "2.x" + } + }, "bser": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.0.tgz", @@ -8590,6 +8599,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", + "dev": true + }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -8663,6 +8678,12 @@ } } }, + "make-error": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.5.tgz", + "integrity": "sha512-c3sIjNUow0+8swNwVpqoH4YCShKNFkMaw6oH1mNS2haDZQqkeZFlHS3dhoeEbKKmJB4vXpJucU6oH75aDYeE9g==", + "dev": true + }, "makeerror": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.11.tgz", @@ -11977,6 +11998,56 @@ "glob": "^7.1.2" } }, + "ts-jest": { + "version": "24.1.0", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-24.1.0.tgz", + "integrity": "sha512-HEGfrIEAZKfu1pkaxB9au17b1d9b56YZSqz5eCVE8mX68+5reOvlM93xGOzzCREIov9mdH7JBG+s0UyNAqr0tQ==", + "dev": true, + "requires": { + "bs-logger": "0.x", + "buffer-from": "1.x", + "fast-json-stable-stringify": "2.x", + "json5": "2.x", + "lodash.memoize": "4.x", + "make-error": "1.x", + "mkdirp": "0.x", + "resolve": "1.x", + "semver": "^5.5", + "yargs-parser": "10.x" + }, + "dependencies": { + "camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", + "dev": true + }, + "json5": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.1.tgz", + "integrity": "sha512-l+3HXD0GEI3huGq1njuqtzYK8OYJyXMkOLtQ53pjWh89tvWS2h6l+1zMkYWqlb57+SiQodKZyvMEFb2X+KrFhQ==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + }, + "yargs-parser": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-10.1.0.tgz", + "integrity": "sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ==", + "dev": true, + "requires": { + "camelcase": "^4.1.0" + } + } + } + }, "ts-loader": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-6.2.0.tgz", diff --git a/package.json b/package.json index a8b72fb1..817fd700 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "pug-plain-loader": "^1.0.0", "sass-loader": "^7.1.0", "shelljs": "^0.6.0", + "ts-jest": "^24.1.0", "ts-loader": "^6.2.0", "tslint": "^5.20.0", "typescript": "^3.6.3", diff --git a/src/queries.js b/src/queries.js index d1ff4456..56c32805 100644 --- a/src/queries.js +++ b/src/queries.js @@ -34,7 +34,7 @@ export function windowQuery(windowbucket, afkbucket, appcount, titlecount, filte not_afk = flood(query_bucket("${afkbucket}")); not_afk = filter_keyvals(not_afk, "status", ["not-afk"]);` + (filterAFK ? 'events = filter_period_intersect(events, not_afk);' : '') + - `events = classify(events, ${JSON.stringify(classes)}); + `events = categorize(events, ${JSON.stringify(classes)}); title_events = sort_by_duration(merge_events_by_keys(events, ["app", "title"])); app_events = sort_by_duration(merge_events_by_keys(title_events, ["app"])); cat_events = sort_by_duration(merge_events_by_keys(events, ["$category"])); diff --git a/src/util/classes.test.js b/src/util/classes.test.js index 25d86efb..494f2a26 100644 --- a/src/util/classes.test.js +++ b/src/util/classes.test.js @@ -1,20 +1,20 @@ const classes = require('./classes'); -let testClasses = [ - { name: 'Test -> Subtest', rule: { type: 'regex', pattern: 'test' } }, - { name: 'Test -> Subtest -> Subsubtest', rule: { type: 'regex', pattern: 'test' } }, +const testClasses = [ + { name: ['Test', 'Subtest'], rule: { type: 'regex', pattern: 'test' } }, + { name: ['Test', 'Subtest', 'Subsubtest'], rule: { type: 'regex', pattern: 'test' } }, ]; test('correctly builds hierarchy', () => { - let result = classes.build_category_hierarchy(testClasses); + const result = classes.build_category_hierarchy(testClasses); expect(result).toHaveLength(1); - let cat_root = result[0]; + const cat_root = result[0]; expect(cat_root.subname).toEqual('Test'); expect(cat_root.children).toHaveLength(1); expect(result[0].children[0].children).toHaveLength(1); }); test('correctly flatten hierarchy', () => { - let result = classes.flatten_category_hierarchy(classes.build_category_hierarchy(testClasses)); + const result = classes.flatten_category_hierarchy(classes.build_category_hierarchy(testClasses)); expect(result).toHaveLength(3); }); diff --git a/src/util/classes.ts b/src/util/classes.ts index 765cbf06..bb4f7fe7 100644 --- a/src/util/classes.ts +++ b/src/util/classes.ts @@ -1,69 +1,75 @@ let _ = require('lodash'); -interface Class { - name?: string, +interface Category { + name: string[], + name_pretty?: string, subname?: string, - rule?: { + rule: { type: string, pattern: string, }, - full_name?: string[], depth?: number, parent?: string[], - children?: Class[], + children?: Category[], } -export let defaultClasses: Class[] = [ - { name: '#test-tag', rule: { type: 'regex', pattern: 'test' } }, - { name: 'Test category -> subcategory', rule: { type: 'regex', pattern: 'test' } }, - { name: 'Work', rule: { type: 'regex', pattern: '[Aa]lacritty|Google Docs' } }, +export let defaultCategories: Category[] = [ + //{ name: '#test-tag', rule: { type: 'regex', pattern: 'test' } }, + { name: ['Test category', 'subcategory'], rule: { type: 'regex', pattern: 'test' } }, + { name: ['Work'], rule: { type: 'regex', pattern: '[Aa]lacritty|Google Docs' } }, { - name: 'Work -> Programming', + name: ['Work', 'Programming'], rule: { type: 'regex', pattern: '\\~/Programming|[Pp]ython|GitHub' }, }, { - name: 'Work -> Programming -> ActivityWatch', + name: ['Work', 'Programming', 'ActivityWatch'], rule: { type: 'regex', pattern: '[Aa]ctivity[Ww]atch|aw-' }, }, - { name: 'Media -> Games', rule: { type: 'regex', pattern: 'Minecraft|RimWorld' } }, - { name: 'Media -> Video', rule: { type: 'regex', pattern: 'YouTube|Plex' } }, - { name: 'Media -> Social Media', rule: { type: 'regex', pattern: 'reddit|Facebook|Twitter' } }, - { name: 'Comms -> IM', rule: { type: 'regex', pattern: 'Messenger' } }, - { name: 'Comms -> Email', rule: { type: 'regex', pattern: 'Gmail' } }, + { name: ['Media', 'Games'], rule: { type: 'regex', pattern: 'Minecraft|RimWorld' } }, + { name: ['Media', 'Video'], rule: { type: 'regex', pattern: 'YouTube|Plex' } }, + { name: ['Media', 'Social Media'], rule: { type: 'regex', pattern: 'reddit|Facebook|Twitter' } }, + { name: ['Comms', 'IM'], rule: { type: 'regex', pattern: 'Messenger' } }, + { name: ['Comms', 'Email'], rule: { type: 'regex', pattern: 'Gmail' } }, ]; -export function build_category_hierarchy(classes: Class[]): Class[] { - function annotateClass(c: Class) { - let ch = c.name.split('->').map(s => s.trim()); - c.name = ch.join('->'); +export function build_category_hierarchy(classes: Category[]): Category[] { + function annotate(c: Category) { + let ch = c.name; + c.name_pretty = ch.join('->'); c.subname = ch.slice(-1)[0]; - c.full_name = ch; c.parent = ch.length > 1 ? ch.slice(0, -1) : null; c.depth = ch.length - 1; return c; } - let new_classes = classes.slice().map(c => annotateClass(c)); + let new_classes = classes.slice().map(c => annotate(c)); // Insert dangling/undefined parents - let all_full_names = new Set(new_classes.map(c => c.full_name.join('->'))); - new_classes - .map(c => c.parent) - .filter(p => !!p) - .map(p => { - let name = p.join('->'); - if (p && !all_full_names.has(name)) { - let new_parent = annotateClass({ name: name, rule: { type: null, pattern: '' } }); - classes.push(new_parent); - all_full_names.add(name); - } - }); + let all_full_names = new Set(new_classes.map(c => c.name.join('->'))); + + function createMissingParents(children) { + children + .map(c => c.parent) + .filter(p => !!p) + .map(p => { + let name = p.join('->'); + if (p && !all_full_names.has(name)) { + let new_parent = annotate({ name: p, rule: { type: null, pattern: '' } }); + classes.push(new_parent); + all_full_names.add(name); + // New parent might not be top-level, so we need to recurse + createMissingParents([new_parent]); + } + }); + } + + createMissingParents(new_classes); - function assignChildren(classes_at_level) { + function assignChildren(classes_at_level: Category[]) { return classes_at_level.map(cls => { cls.children = classes.filter(child => { - return child.parent && cls.full_name - ? JSON.stringify(child.parent) == JSON.stringify(cls.full_name) + return child.parent && cls.name + ? JSON.stringify(child.parent) == JSON.stringify(cls.name) : false; }); assignChildren(cls.children); @@ -74,7 +80,7 @@ export function build_category_hierarchy(classes: Class[]): Class[] { return assignChildren(classes.filter(c => !c.parent)); } -export function flatten_category_hierarchy(hier: Class[]): Class[] { +export function flatten_category_hierarchy(hier: Category[]): Category[] { return _.flattenDeep( hier.map(h => { let level = [h, flatten_category_hierarchy(h.children)]; @@ -84,21 +90,22 @@ export function flatten_category_hierarchy(hier: Class[]): Class[] { ); } -export function saveClasses(classes: Class[]) { +export function saveClasses(classes: Category[]) { localStorage.classes = JSON.stringify(classes); console.log('Saved classes', localStorage.classes); } -export function loadClasses(): Class[] { - let classes = JSON.parse(localStorage.classes); - console.log(classes); - if (classes.length < 1) { - console.log('Entered if'); - classes = defaultClasses; +export function loadClasses(): Category[] { + let classes_json = localStorage.classes; + let classes; + if(classes_json && classes_json.length >= 1) { + classes = JSON.parse(localStorage.classes); + } else { + classes = defaultCategories; } return classes; } -export function loadClassesForQuery(): string[][] { - return loadClasses().map(c => [c.name, c.rule.pattern]); +export function loadClassesForQuery(): [string[], any][] { + return loadClasses().map(c => [c.name, { regex: c.rule.pattern }]); } diff --git a/src/views/CategoryEditTree.vue b/src/views/CategoryEditTree.vue index bd1a337d..fd3b7dfd 100644 --- a/src/views/CategoryEditTree.vue +++ b/src/views/CategoryEditTree.vue @@ -3,8 +3,8 @@ div div.row.my-2 div.col-8.col-md-4 span(:style="{ marginLeft: (2 * depth) + 'em'}") - //| {{ cls.name.split("->").slice(0, -1).join(" ➤ ")}} - | #[span(v-if="depth > 0") ➤] {{ cls.name.split("->").slice(-1).join(" ➤ ")}} + //| {{ cls.name.join(" ➤ ")}} + | #[span(v-if="depth > 0") ➤] {{ cls.name.join(" ➤ ")}} //b-input-group.my-1 b-form-input(v-model="cls.subname") div.col-4.col-md-8 diff --git a/src/views/activity/daily/ActivityDailySummary.vue b/src/views/activity/daily/ActivityDailySummary.vue index 519ed71b..a40605e3 100644 --- a/src/views/activity/daily/ActivityDailySummary.vue +++ b/src/views/activity/daily/ActivityDailySummary.vue @@ -27,7 +27,7 @@ div div.col-md-4 h5 Top categories div(v-if="top_categories") - aw-summary(:fields="top_categories", :namefunc="e => e.data['$category']", :colorfunc="e => e.data['$category']") + aw-summary(:fields="top_categories", :namefunc="e => e.data['$category'].join(' -> ')", :colorfunc="e => e.data['$category'].join(' -> ')") b-button(size="sm", variant="outline-secondary", :disabled="top_categories.length < top_cats_count" @click="top_cats_count += 5") icon(name="angle-double-down") | Show more diff --git a/src/visualizations/CategoryTree.vue b/src/visualizations/CategoryTree.vue index 11129715..7e936b40 100644 --- a/src/visualizations/CategoryTree.vue +++ b/src/visualizations/CategoryTree.vue @@ -17,16 +17,12 @@ svg { diff --git a/src/views/activity/daily/ActivityDaily.vue b/src/views/activity/daily/ActivityDaily.vue index add3b8f5..3acd4508 100644 --- a/src/views/activity/daily/ActivityDaily.vue +++ b/src/views/activity/daily/ActivityDaily.vue @@ -1,11 +1,11 @@ diff --git a/src/views/activity/daily/ActivityDailyEditor.vue b/src/views/activity/daily/ActivityDailyEditor.vue index 0d5b29f5..32511dda 100644 --- a/src/views/activity/daily/ActivityDailyEditor.vue +++ b/src/views/activity/daily/ActivityDailyEditor.vue @@ -22,19 +22,15 @@ div div.row(style="padding-top: 0.5em;") div.col-md-4 h5 Top file activity - aw-summary(:fields="top_editor_files", :namefunc="top_editor_files_namefunc", :colorfunc="top_editor_files_colorfunc") + aw-summary(:fields="top_editor_files", :namefunc="top_editor_files_namefunc", :colorfunc="top_editor_files_colorfunc", with_limit) div.col-md-4 h5 Top language activity - aw-summary(:fields="top_editor_languages", :namefunc="top_editor_languages_namefunc", :colorfunc="top_editor_languages_colorfunc") + aw-summary(:fields="top_editor_languages", :namefunc="top_editor_languages_namefunc", :colorfunc="top_editor_languages_colorfunc", with_limit) div.col-md-4 h5 Top project activity - aw-summary(:fields="top_editor_projects", :namefunc="top_editor_projects_namefunc", :colorfunc="top_editor_projects_colorfunc") - - b-button(size="sm", variant="outline-secondary", @click="top_editor_count += 5; queryEditorActivity()") - icon(name="angle-double-down") - | Show more + aw-summary(:fields="top_editor_projects", :namefunc="top_editor_projects_namefunc", :colorfunc="top_editor_projects_colorfunc", with_limit) diff --git a/src/views/activity/daily/ActivityDailyWindow.vue b/src/views/activity/daily/ActivityDailyWindow.vue index d559bf94..0d4306af 100644 --- a/src/views/activity/daily/ActivityDailyWindow.vue +++ b/src/views/activity/daily/ActivityDailyWindow.vue @@ -12,38 +12,21 @@ div diff --git a/src/visualizations/CategoryTree.vue b/src/visualizations/CategoryTree.vue index 7e936b40..0842da52 100644 --- a/src/visualizations/CategoryTree.vue +++ b/src/visualizations/CategoryTree.vue @@ -66,7 +66,7 @@ export default { return _.flattenDeep([c, _.map(c.children, cc => _flatten_hierarchy(cc))]); } cats = _.flatten(_.map(cats_with_depth0, c => _flatten_hierarchy(c))); - console.log(cats); + //console.log(cats); // TODO: If a category has children, but also activity attributed directly to the parent that does not belong to a child, then create a "Other" child containing the activity. return cats; } diff --git a/src/visualizations/Summary.vue b/src/visualizations/Summary.vue index 20c3f26f..06fe8a4b 100644 --- a/src/visualizations/Summary.vue +++ b/src/visualizations/Summary.vue @@ -1,5 +1,9 @@ From 3ac9190952336d12a00810d4160b3418a0de5876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Sat, 19 Oct 2019 17:27:10 +0200 Subject: [PATCH 27/32] finishing touches on category editor and other category stuff --- package-lock.json | 9 ++++ package.json | 1 + src/components/Header.vue | 4 +- src/store/modules/settings.js | 32 +++++++++---- src/util/classes.ts | 20 ++++----- src/views/CategoryEditTree.vue | 45 ++++++++++++------- src/views/Settings.vue | 43 ++++++++++-------- .../activity/daily/ActivityDailySummary.vue | 2 +- src/visualizations/CategoryTree.vue | 2 +- src/visualizations/SunburstCategories.vue | 10 ----- vue.config.js | 6 +++ 11 files changed, 108 insertions(+), 66 deletions(-) diff --git a/package-lock.json b/package-lock.json index 234628e6..d9fe7241 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20168,6 +20168,15 @@ "raw-loader": "^0.5.1" } }, + "vue-cli-plugin-webpack-bundle-analyzer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/vue-cli-plugin-webpack-bundle-analyzer/-/vue-cli-plugin-webpack-bundle-analyzer-1.4.0.tgz", + "integrity": "sha512-x7NIDe17IewKc/RaIkM2JBVGJLIKqlscxYKFJ130++yncU6zelAnwIVAKZI9cmtTi3nDJENB44H1BLi4CeU84A==", + "dev": true, + "requires": { + "webpack-bundle-analyzer": "^3.3.2" + } + }, "vue-d3-sunburst": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/vue-d3-sunburst/-/vue-d3-sunburst-1.0.0.tgz", diff --git a/package.json b/package.json index 6c15ff7d..1779090a 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "url-loader": "^1.1.0", "vue": "^2.6.10", "vue-cli-plugin-pug": "^1.0.7", + "vue-cli-plugin-webpack-bundle-analyzer": "^1.4.0", "vue-eslint-parser": "^6.0.4", "vue-hot-reload-api": "^2.3.0", "vue-loader": "latest", diff --git a/src/components/Header.vue b/src/components/Header.vue index f788aff2..6a96e600 100644 --- a/src/components/Header.vue +++ b/src/components/Header.vue @@ -12,7 +12,7 @@ div.aw-navbar b-collapse#nav-collapse(is-nav) b-navbar-nav // If only a single view (the default) is available - b-nav-item(v-if="activityViewsDaily.length === 1", v-for="view in activityViewsDaily", :key="view.name", :to="view.pathUrl + '/' + view.hostname") + b-nav-item(v-if="activityViewsDaily.length === 1", v-for="view in activityViewsDaily", :key="view.name", :to="view.pathUrl + '/' + view.hostname + '/summary'") div.px-2.px-lg-1 icon(name="calendar-day") | Today @@ -32,7 +32,7 @@ div.aw-navbar | {{ view.name }} // If only a single view (the default) is available - b-nav-item(v-if="activityViewsSummary.length === 1", v-for="view in activityViewsSummary", :key="view.name + '_summary'", :to="view.pathUrl + '/' + view.hostname + '/summary'") + b-nav-item(v-if="activityViewsSummary.length === 1", v-for="view in activityViewsSummary", :key="view.name + '_summary'", :to="view.pathUrl + '/' + view.hostname") div.px-2.px-lg-1 icon(name="calendar-week") | Summary diff --git a/src/store/modules/settings.js b/src/store/modules/settings.js index 272d2bbe..508a4469 100644 --- a/src/store/modules/settings.js +++ b/src/store/modules/settings.js @@ -4,12 +4,14 @@ import { saveClasses, loadClasses, build_category_hierarchy } from '~/util/class // initial state const _state = { classes: [], + classes_unsaved_changes: false, }; // getters const getters = { classes_hierarchy: state => { - return build_category_hierarchy(_.cloneDeep(state.classes)); + const hier = build_category_hierarchy(_.cloneDeep(state.classes)); + return _.sortBy(hier, [c => c.id || 0]); }, all_categories: state => { return _.uniqBy( @@ -32,8 +34,10 @@ const actions = { async load({ commit }) { commit('loadClasses', await loadClasses()); }, - async save({ state }) { - return saveClasses(state.classes); + async save({ state, commit }) { + const r = await saveClasses(state.classes); + commit('saveCompleted'); + return r; }, }; @@ -42,20 +46,32 @@ const mutations = { loadClasses(state, classes) { let i = 0; state.classes = classes.map(c => Object.assign(c, { id: i++ })); - console.log(state.classes); + console.log('Loaded classes:', state.classes); + state.classes_unsaved_changes = false; }, updateClass(state, new_class) { + // FIXME: When renaming parent, also rename children console.log('Updating class:', new_class); - if (new_class.id === undefined) { - new_class.id = _.maxBy(state.classes, c => c.id) + 1; + if (new_class.id === undefined || new_class.id === null) { + new_class.id = _.max(_.map(state.classes, 'id')) + 1; state.classes.push(new_class); } else { - state.classes[new_class.id] = new_class; + Object.assign(state.classes[new_class.id], new_class); } + state.classes_unsaved_changes = true; + console.log(state.classes); }, addClass(state, new_class) { - new_class.id = _.maxBy(state.classes, c => c.id) + 1; + new_class.id = _.max(_.map(state.classes, 'id')) + 1; state.classes.push(new_class); + state.classes_unsaved_changes = true; + }, + removeClass(state, cls) { + state.classes = state.classes.filter(c => c.id !== cls.id); + state.classes_unsaved_changes = true; + }, + saveCompleted(state) { + state.classes_unsaved_changes = false; }, }; diff --git a/src/util/classes.ts b/src/util/classes.ts index 3e388d87..0953cf08 100644 --- a/src/util/classes.ts +++ b/src/util/classes.ts @@ -1,6 +1,9 @@ let _ = require('lodash'); +const level_sep = ">"; + interface Category { + id?: number, name: string[]; name_pretty?: string; subname?: string; @@ -14,13 +17,10 @@ interface Category { } export let defaultCategories: Category[] = [ - //{ name: '#test-tag', rule: { type: 'regex', pattern: 'test' } }, - { name: ['Test category', 'subcategory'], rule: { type: 'regex', pattern: 'test' } }, - { name: ['Test'], rule: { type: null, pattern: '[Aa]lacritty|Google Docs' } }, - { name: ['Work'], rule: { type: 'regex', pattern: '[Aa]lacritty|Google Docs' } }, + { name: ['Work'], rule: { type: 'regex', pattern: 'Google Docs' } }, { name: ['Work', 'Programming'], - rule: { type: 'regex', pattern: '\\~/Programming|[Pp]ython|GitHub' }, + rule: { type: 'regex', pattern: 'GitHub|Stack Overflow' }, }, { name: ['Work', 'Programming', 'ActivityWatch'], @@ -28,15 +28,15 @@ export let defaultCategories: Category[] = [ }, { name: ['Media', 'Games'], rule: { type: 'regex', pattern: 'Minecraft|RimWorld' } }, { name: ['Media', 'Video'], rule: { type: 'regex', pattern: 'YouTube|Plex' } }, - { name: ['Media', 'Social Media'], rule: { type: 'regex', pattern: 'reddit|Facebook|Twitter' } }, - { name: ['Comms', 'IM'], rule: { type: 'regex', pattern: 'Messenger' } }, + { name: ['Media', 'Social Media'], rule: { type: 'regex', pattern: 'reddit|Facebook|Twitter|Instagram' } }, + { name: ['Comms', 'IM'], rule: { type: 'regex', pattern: 'Messenger|Telegram|Signal|WhatsApp' } }, { name: ['Comms', 'Email'], rule: { type: 'regex', pattern: 'Gmail' } }, ]; export function build_category_hierarchy(classes: Category[]): Category[] { function annotate(c: Category) { let ch = c.name; - c.name_pretty = ch.join('->'); + c.name_pretty = ch.join(level_sep); c.subname = ch.slice(-1)[0]; c.parent = ch.length > 1 ? ch.slice(0, -1) : null; c.depth = ch.length - 1; @@ -46,14 +46,14 @@ export function build_category_hierarchy(classes: Category[]): Category[] { let new_classes = classes.slice().map(c => annotate(c)); // Insert dangling/undefined parents - let all_full_names = new Set(new_classes.map(c => c.name.join('->'))); + let all_full_names = new Set(new_classes.map(c => c.name.join(level_sep))); function createMissingParents(children) { children .map(c => c.parent) .filter(p => !!p) .map(p => { - let name = p.join('->'); + let name = p.join(level_sep); if (p && !all_full_names.has(name)) { let new_parent = annotate({ name: p, rule: { type: null, pattern: '' } }); classes.push(new_parent); diff --git a/src/views/CategoryEditTree.vue b/src/views/CategoryEditTree.vue index c2b0393b..b78cce81 100644 --- a/src/views/CategoryEditTree.vue +++ b/src/views/CategoryEditTree.vue @@ -1,6 +1,6 @@ + + diff --git a/src/views/Settings.vue b/src/views/Settings.vue index 723c45f6..81ea9d2a 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -10,49 +10,56 @@ div div.col-sm-9 h5.mb-0 Start of day small - | Sets the time where the start/end of a day is in the daily activity view. Set to 04:00 by default. + | The time at which days "start", since humans don't always go to bed before midnight. Set to 04:00 by default. div.col-sm-3 input.form-control(type="time" :value="startOfDay" @change="setStartOfDay($event.target.value)") hr - h5 - div Tagging & Categorization - b-btn.float-right.ml-1(@click="resetClasses", variant="warning" size="sm") - | Reset - b-btn.float-right.ml-1(@click="restoreDefaults", variant="warning" size="sm") + h5.d-inline-block + div Categorization + div.float-right + b-btn.ml-1(@click="restoreDefaults", variant="warning" size="sm") | Restore defaults div - small An event can have many tags, but only one category. If several categories match, the deepest one will be chosen. + | An event can have many tags, but only one category. If several categories match, the deepest one will be chosen. + + div.my-4 + b-alert(variant="warning" :show="classes_unsaved_changes") + | You have unsaved changes! + b-btn.float-right.ml-1(@click="resetClasses", variant="warning" size="sm") + | Reset + div(v-for="cls in classes_hierarchy") + CategoryEditTree(:cls="cls") - h6 Categories - div(v-for="cls in $store.getters['settings/classes_hierarchy']") - CategoryEditTree(:cls="cls") div.row div.col-sm-12 - b-btn(@click="addClass") Add new class - b-btn.float-right(@click="saveClasses", variant="success") - | Save tagging rules + b-btn(@click="addClass") Add category + b-btn.float-right(@click="saveClasses", variant="success" :disabled="!classes_unsaved_changes") + | Save