From 382308f378910060797eadce69e9e4ebef5723e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Fri, 30 Oct 2020 12:04:20 +0100 Subject: [PATCH 01/11] feat: started working on customizable views --- src/components/SelectableVisualization.vue | 22 ++++++ src/route.js | 10 ++- src/store/modules/settings.js | 42 +++++++++++ src/util/transforms.js | 38 ++++++++++ src/views/activity/Activity.vue | 26 ++++--- src/views/activity/ActivitySummary.vue | 87 +--------------------- src/views/activity/ActivityView.vue | 41 ++++++++++ 7 files changed, 169 insertions(+), 97 deletions(-) create mode 100644 src/util/transforms.js create mode 100644 src/views/activity/ActivityView.vue diff --git a/src/components/SelectableVisualization.vue b/src/components/SelectableVisualization.vue index 19a78935..2e47d980 100644 --- a/src/components/SelectableVisualization.vue +++ b/src/components/SelectableVisualization.vue @@ -54,6 +54,8 @@ div aw-categorytree(:events="$store.state.activity.category.top") div(v-if="type == 'category_sunburst'") aw-sunburst-categories(:data="top_categories_hierarchy", style="height: 20em") + div(v-if="type == 'timeline_barchart'") + aw-timeline-barchart(:datasets="datasets", style="height: 100") @@ -70,8 +72,11 @@ div diff --git a/src/route.js b/src/route.js index 48cc6466..13698eaf 100644 --- a/src/route.js +++ b/src/route.js @@ -5,6 +5,7 @@ const Home = () => import('./views/Home.vue'); // Activity views for desktop const Activity = () => import('./views/activity/Activity.vue'); +const ActivityView = () => import('./views/activity/ActivityView.vue'); const ActivitySummary = () => import('./views/activity/ActivitySummary.vue'); const ActivityWindow = () => import('./views/activity/ActivityWindow.vue'); const ActivityBrowser = () => import('./views/activity/ActivityBrowser.vue'); @@ -28,6 +29,13 @@ const router = new VueRouter({ component: Activity, props: true, children: [ + { + path: 'view/:view_id?', + meta: { subview: 'view' }, + name: 'activity-view', + component: ActivityView, + props: true, + }, { path: 'summary', meta: { subview: 'summary' }, @@ -58,7 +66,7 @@ const router = new VueRouter({ // (needs to be last since otherwise it'll always match first) { path: '', - redirect: 'summary', + redirect: 'view/', }, ], }, diff --git a/src/store/modules/settings.js b/src/store/modules/settings.js index 707410d4..2c2b6fe0 100644 --- a/src/store/modules/settings.js +++ b/src/store/modules/settings.js @@ -6,8 +6,40 @@ import { build_category_hierarchy, } from '~/util/classes'; +const defaultViews = [ + { + id: 'summary', + name: 'Summary', + elements: [ + { type: 'top_apps', size: 3 }, + { type: 'top_titles', size: 3 }, + { type: 'top_domains', size: 3 }, + { type: 'top_categories', size: 3 }, + { type: 'category_tree', size: 3 }, + { type: 'category_sunburst', size: 3 }, + ], + }, + { + id: 'window', + name: 'Window', + elements: [ + { type: 'top_apps', size: 3 }, + { type: 'top_titles', size: 3 }, + ], + }, + { + id: 'browser', + name: 'Browser', + elements: [ + { type: 'top_domains', size: 3 }, + { type: 'top_urls', size: 3 }, + ], + }, +]; + // initial state const _state = { + views: [], classes: [], classes_unsaved_changes: false, }; @@ -37,6 +69,7 @@ const getters = { // actions const actions = { async load({ commit }) { + commit('loadViews'); commit('loadClasses', await loadClasses()); }, async save({ state, commit }) { @@ -48,6 +81,15 @@ const actions = { // mutations const mutations = { + loadViews(state) { + const views_json = localStorage.views; + if (views_json && views_json.length >= 1) { + state.views = JSON.parse(views_json); + } else { + state.views = defaultViews; + } + console.log('Loaded views:', state.views); + }, loadClasses(state, classes) { let i = 0; state.classes = classes.map(c => Object.assign(c, { id: i++ })); diff --git a/src/util/transforms.js b/src/util/transforms.js new file mode 100644 index 00000000..7c5b830a --- /dev/null +++ b/src/util/transforms.js @@ -0,0 +1,38 @@ +import _ from 'lodash'; +import moment from 'moment'; + +// TODO: Move somewhere else, possibly turn into a serverside transform +export function split_by_hour_into_data(events) { + if (events === undefined || events === null || events.length == 0) return []; + const d = moment(events[0].timestamp).startOf('day'); + return _.range(0, 24).map(h => { + let duration = 0; + const d_start = d.clone().hour(h); + const d_end = d.clone().hour(h + 1); + // This can be made faster by not checking every event per hour, but since number of events is small anyway this and this is a lot shorter and easier to read it doesn't really matter. + events.map(e => { + const e_start = moment(e.timestamp); + const e_end = e_start.clone().add(e.duration, 'seconds'); + if ( + e_start.isBetween(d_start, d_end) || + e_end.isBetween(d_start, d_end) || + d_start.isBetween(e_start, e_end) + ) { + if (d_start < e_start && e_end < d_end) { + // If entire event is contained within the hour + duration += e.duration; + } else if (d_start < e_start) { + // If start of event is within the hour, but not the end + duration += (d_end - e_start) / 1000; + } else if (e_end < d_end) { + // If end of event is within the hour, but not the start + duration += (e_end - d_start) / 1000; + } else { + // Happens if event covers entire hour and more + duration += 3600; + } + } + }); + return duration / 60 / 60; + }); +} diff --git a/src/views/activity/Activity.vue b/src/views/activity/Activity.vue index 277987e9..e37656ed 100644 --- a/src/views/activity/Activity.vue +++ b/src/views/activity/Activity.vue @@ -36,19 +36,18 @@ div aw-periodusage(:periodusage_arr="periodusage", @update="setDate") - ul.row.nav.nav-tabs.mt-3.pl-3 - li.nav-item - router-link.nav-link(:to="{ name: 'activity-summary', params: $route.params }") - h6 Summary - li.nav-item - router-link.nav-link(:to="{ name: 'activity-window', params: $route.params }") - h6 Window - li.nav-item - router-link.nav-link(:to="{ name: 'activity-browser', params: $route.params }") - h6 Browser + ul.row.nav.nav-tabs.mt-3.px-3 + li.nav-item(v-for="view in views") + router-link.nav-link(:to="{ name: 'activity-view', params: {...$route.params, view_id: view.id}}") + h6 {{view.name}} li.nav-item router-link.nav-link(:to="{ name: 'activity-editor', params: $route.params }") h6 Editor + li.nav-item(style="margin-left: auto") + a.nav-link(@click="addView") + h6 + icon(name="plus") + span New view div router-view @@ -110,6 +109,7 @@ import _ from 'lodash'; import 'vue-awesome/icons/arrow-left'; import 'vue-awesome/icons/arrow-right'; import 'vue-awesome/icons/sync'; +import 'vue-awesome/icons/plus'; export default { name: 'Activity', @@ -135,6 +135,9 @@ export default { }; }, computed: { + views: function () { + return this.$store.state.settings.views; + }, _date: function () { return this.date || get_today(); }, @@ -205,6 +208,9 @@ export default { }, methods: { + addView: function () { + alert('Not implemented yet'); + }, previousPeriod: function () { return moment(this._date).subtract(1, `${this.periodLength}s`).format('YYYY-MM-DD'); }, diff --git a/src/views/activity/ActivitySummary.vue b/src/views/activity/ActivitySummary.vue index 86a80f5c..a175f135 100644 --- a/src/views/activity/ActivitySummary.vue +++ b/src/views/activity/ActivitySummary.vue @@ -7,57 +7,10 @@ div aw-devonly(v-if="periodLength === 'day'" reason="Not ready for production, still experimenting") div.row.mb-4 div.col-md-12 - aw-timeline-barchart(:height="100", :datasets="datasets") + aw-selectable-vis(:id="index" type="timeline_barchart") From 783733b50a1eefbc2acccbeb011d8baf32424b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Fri, 30 Oct 2020 12:20:57 +0100 Subject: [PATCH 02/11] feat: continued work on customizable views, fixing a few bugs --- src/components/SelectableVisualization.vue | 1 + src/route.js | 23 --------- src/store/modules/settings.js | 5 ++ src/views/activity/ActivityBrowser.vue | 56 --------------------- src/views/activity/ActivitySummary.vue | 57 ---------------------- src/views/activity/ActivityView.vue | 17 ++++--- src/views/activity/ActivityWindow.vue | 53 -------------------- 7 files changed, 15 insertions(+), 197 deletions(-) delete mode 100644 src/views/activity/ActivityBrowser.vue delete mode 100644 src/views/activity/ActivitySummary.vue delete mode 100644 src/views/activity/ActivityWindow.vue diff --git a/src/components/SelectableVisualization.vue b/src/components/SelectableVisualization.vue index 2e47d980..3d1d62ec 100644 --- a/src/components/SelectableVisualization.vue +++ b/src/components/SelectableVisualization.vue @@ -172,6 +172,7 @@ export default { timeline_barchart: { title: 'Timeline (barchart)', // TODO + available: true, //available: this.$store.state.activity.category.available, }, }; diff --git a/src/route.js b/src/route.js index 13698eaf..3052c1c3 100644 --- a/src/route.js +++ b/src/route.js @@ -6,9 +6,6 @@ const Home = () => import('./views/Home.vue'); // Activity views for desktop const Activity = () => import('./views/activity/Activity.vue'); const ActivityView = () => import('./views/activity/ActivityView.vue'); -const ActivitySummary = () => import('./views/activity/ActivitySummary.vue'); -const ActivityWindow = () => import('./views/activity/ActivityWindow.vue'); -const ActivityBrowser = () => import('./views/activity/ActivityBrowser.vue'); const ActivityEditor = () => import('./views/activity/ActivityEditor.vue'); const Buckets = () => import('./views/Buckets.vue'); @@ -36,26 +33,6 @@ const router = new VueRouter({ component: ActivityView, props: true, }, - { - path: 'summary', - meta: { subview: 'summary' }, - name: 'activity-summary', - component: ActivitySummary, - props: true, - }, - { - path: 'window', - meta: { subview: 'window' }, - name: 'activity-window', - component: ActivityWindow, - props: true, - }, - { - path: 'browser', - meta: { subview: 'browser' }, - name: 'activity-browser', - component: ActivityBrowser, - }, { path: 'editor', meta: { subview: 'editor' }, diff --git a/src/store/modules/settings.js b/src/store/modules/settings.js index 2c2b6fe0..19fdc89a 100644 --- a/src/store/modules/settings.js +++ b/src/store/modules/settings.js @@ -90,6 +90,11 @@ const mutations = { } console.log('Loaded views:', state.views); }, + editView(state, { view_id, el_id, type }) { + console.log(view_id, el_id, type); + console.log(state.views); + state.views.find(v => v.id == view_id).elements[el_id].type = type; + }, loadClasses(state, classes) { let i = 0; state.classes = classes.map(c => Object.assign(c, { id: i++ })); diff --git a/src/views/activity/ActivityBrowser.vue b/src/views/activity/ActivityBrowser.vue deleted file mode 100644 index a010be09..00000000 --- a/src/views/activity/ActivityBrowser.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - diff --git a/src/views/activity/ActivitySummary.vue b/src/views/activity/ActivitySummary.vue deleted file mode 100644 index a175f135..00000000 --- a/src/views/activity/ActivitySummary.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - diff --git a/src/views/activity/ActivityView.vue b/src/views/activity/ActivityView.vue index 74659070..94afd55f 100644 --- a/src/views/activity/ActivityView.vue +++ b/src/views/activity/ActivityView.vue @@ -1,10 +1,10 @@ @@ -29,12 +29,13 @@ export default { }, }, methods: { - onTypeChange(id, type) { - // TODO: Use vuex store - console.log(this.$store.state.settings.views); - //this.elements[id] = type; - // Needed to emit the change to the child component - //this.$set(this.elements, this.elements); + addVisualization: function () { + alert('not implemented'); + }, + async onTypeChange(id, type) { + const view_id = + this.view_id == 'default' ? this.$store.state.settings.views[0].id : this.view_id; + await this.$store.commit('settings/editView', { view_id: view_id, el_id: id, type }); }, }, }; diff --git a/src/views/activity/ActivityWindow.vue b/src/views/activity/ActivityWindow.vue deleted file mode 100644 index bf112b19..00000000 --- a/src/views/activity/ActivityWindow.vue +++ /dev/null @@ -1,53 +0,0 @@ - - - From 6578e7f217f4fe02d9c0b3d76a1e8d7c4ec8d18c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Fri, 30 Oct 2020 12:34:03 +0100 Subject: [PATCH 03/11] feat: added ability to add new views and visualizations --- src/store/modules/settings.js | 6 ++++++ src/views/activity/Activity.vue | 5 ++++- src/views/activity/ActivityView.vue | 4 +++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/store/modules/settings.js b/src/store/modules/settings.js index 19fdc89a..899dfe74 100644 --- a/src/store/modules/settings.js +++ b/src/store/modules/settings.js @@ -90,11 +90,17 @@ const mutations = { } console.log('Loaded views:', state.views); }, + addView(state, { view_id }) { + state.views.push({ id: view_id, name: view_id, elements: [] }); + }, editView(state, { view_id, el_id, type }) { console.log(view_id, el_id, type); console.log(state.views); state.views.find(v => v.id == view_id).elements[el_id].type = type; }, + addVisualization(state, { view_id, type }) { + state.views.find(v => v.id == view_id).elements.push({ type: type }); + }, loadClasses(state, classes) { let i = 0; state.classes = classes.map(c => Object.assign(c, { id: i++ })); diff --git a/src/views/activity/Activity.vue b/src/views/activity/Activity.vue index e37656ed..62d52d11 100644 --- a/src/views/activity/Activity.vue +++ b/src/views/activity/Activity.vue @@ -209,7 +209,10 @@ export default { methods: { addView: function () { - alert('Not implemented yet'); + // TODO: Open modal to ask for options like id, and name + this.$store.commit('settings/addView', { + view_id: this.$store.state.settings.views.length + 1, + }); }, previousPeriod: function () { return moment(this._date).subtract(1, `${this.periodLength}s`).format('YYYY-MM-DD'); diff --git a/src/views/activity/ActivityView.vue b/src/views/activity/ActivityView.vue index 94afd55f..b57d3291 100644 --- a/src/views/activity/ActivityView.vue +++ b/src/views/activity/ActivityView.vue @@ -30,7 +30,9 @@ export default { }, methods: { addVisualization: function () { - alert('not implemented'); + const view_id = + this.view_id == 'default' ? this.$store.state.settings.views[0].id : this.view_id; + this.$store.commit('settings/addVisualization', { view_id, type: 'top_apps' }); }, async onTypeChange(id, type) { const view_id = From 50e2c0dcc6506dffc50e12530027a9ff9d006bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Sun, 1 Nov 2020 12:41:38 +0100 Subject: [PATCH 04/11] test: fixed e2e screenshot test --- test/e2e/screenshot.test.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/e2e/screenshot.test.js b/test/e2e/screenshot.test.js index 068b27e0..24a6f5ac 100644 --- a/test/e2e/screenshot.test.js +++ b/test/e2e/screenshot.test.js @@ -14,10 +14,13 @@ test('Take a screenshot of the activity view', async t => { // The resolution is the one used by the testcafe-action: // https://github.com/DevExpress/testcafe-action/blob/0989d5f8ad852d71298ce3b770442cdec309d479/index.js#L59-L60 //.resizeWindow(1280, 720) - .click('#load-demo') - // TODO: Figure out how to click all hide-devonly buttons instead of hardcoding number of clicks - .click('.hide-devonly') - .click('.hide-devonly'); + .click('#load-demo'); + + // Hide all devonly-elements + const $hidedevonly = Selector('.hide-devonly'); + for (let i = 0; i < $hidedevonly.count; i++) { + await t.click($hidedevonly.nth(i)); + } // Closes the 'Network Error' message if running without aw-server const $close = Selector('.close'); From 1beade254983c6253bb7db647046f578d7cc21b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Sun, 1 Nov 2020 12:43:15 +0100 Subject: [PATCH 05/11] feat: added buttons to edit/save view, made ActivityView delegate to ActivityEditor if view_id is 'editor' --- src/components/SelectableVisualization.vue | 12 ++++-- src/route.js | 7 ---- src/store/modules/settings.js | 10 +++++ src/views/activity/Activity.vue | 19 +++++++--- src/views/activity/ActivityEditor.vue | 2 +- src/views/activity/ActivityView.vue | 44 +++++++++++++++++++--- 6 files changed, 72 insertions(+), 22 deletions(-) diff --git a/src/components/SelectableVisualization.vue b/src/components/SelectableVisualization.vue index 3d1d62ec..8d40184a 100644 --- a/src/components/SelectableVisualization.vue +++ b/src/components/SelectableVisualization.vue @@ -1,12 +1,14 @@ From 94f0959c3c84f78829605a918e6f7b6930a66d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Mon, 2 Nov 2020 13:10:09 +0100 Subject: [PATCH 10/11] feat: refactored ActivityEditor into ActivityView, added better info when missing data --- src/components/SelectableVisualization.vue | 13 +++- src/store/modules/views.js | 4 +- src/views/activity/ActivityEditor.vue | 85 ---------------------- src/views/activity/ActivityView.vue | 6 -- 4 files changed, 14 insertions(+), 94 deletions(-) delete mode 100644 src/views/activity/ActivityEditor.vue diff --git a/src/components/SelectableVisualization.vue b/src/components/SelectableVisualization.vue index 8d40184a..84e59254 100644 --- a/src/components/SelectableVisualization.vue +++ b/src/components/SelectableVisualization.vue @@ -5,11 +5,17 @@ div b-dropdown.mr-1(size="sm" variant="outline-secondary") template(v-slot:button-content) icon(name="cog") - b-dropdown-item(v-for="t in types" :key="t" variant="outline-secondary" @click="$emit('onTypeChange', id, t)" v-bind:disabled="!visualizations[t].available") - | {{ visualizations[t].title }} + b-dropdown-item(v-for="t in types" :key="t" variant="outline-secondary" @click="$emit('onTypeChange', id, t)") + | {{ visualizations[t].title }} #[span.small(v-if="!visualizations[t].available" style="color: #A50") (no data)] b-button.p-0(size="sm", variant="outline-danger" @click="$emit('onRemove', id)") icon(name="times") + // Check data prerequisites + div(v-if="!has_prerequisites") + b-alert.small.px-2.py-1(show variant="warning") + | This feature is missing data from a required watcher. + | You can find a list of all watchers in #[a(href="https://activitywatch.readthedocs.io/en/latest/watchers.html") the documentation]. + div(v-if="type == 'top_apps'") aw-summary(:fields="$store.state.activity.window.top_apps", :namefunc="e => e.data.app", @@ -183,6 +189,9 @@ export default { }, }; }, + has_prerequisites() { + return this.visualizations[this.type].available; + }, top_categories_hierarchy: function () { const top_categories = this.$store.state.activity.category.top; if (top_categories) { diff --git a/src/store/modules/views.js b/src/store/modules/views.js index eb7d6628..4efaf2d8 100644 --- a/src/store/modules/views.js +++ b/src/store/modules/views.js @@ -31,7 +31,9 @@ const defaultViews = [ id: 'editor', name: 'Editor', elements: [ - // TODO: Migrate ActivityEditor to ActivityView + { type: 'top_editor_files', size: 3 }, + { type: 'top_editor_projects', size: 3 }, + { type: 'top_editor_languages', size: 3 }, ], }, ]; diff --git a/src/views/activity/ActivityEditor.vue b/src/views/activity/ActivityEditor.vue deleted file mode 100644 index 74beafe2..00000000 --- a/src/views/activity/ActivityEditor.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - diff --git a/src/views/activity/ActivityView.vue b/src/views/activity/ActivityView.vue index 94021787..d6c02cdc 100644 --- a/src/views/activity/ActivityView.vue +++ b/src/views/activity/ActivityView.vue @@ -1,8 +1,5 @@