Skip to content

Commit

Permalink
feat(ux): improve timeline usability (#610)
Browse files Browse the repository at this point in the history
* require fewer clicks for time interval changes. apply bootstrap styles.

* notify user, that timeline was not updated / re-rendered on purpose (because there are no matching events to render). addresses issues reported in #395

* update lastUpdate timer display every 500ms

* use for loop to render duration buttons. replace sr-only by d-none.

* extra margin for small screens.

* re-add sync button next to last update label. always show it, and label it "reload" instead of former "update".

* Apply suggestions from code review

* tests: fixed e2e test for new timeline inputs

* fix: minor style improvements to new timeline

---------

Co-authored-by: Erik Bjäreholt <erik.bjareholt@gmail.com>
Co-authored-by: Erik Bjäreholt <erik@bjareho.lt>
  • Loading branch information
3 people authored Oct 5, 2024
1 parent 85c007e commit 291da6f
Show file tree
Hide file tree
Showing 4 changed files with 78 additions and 56 deletions.
111 changes: 67 additions & 44 deletions src/components/InputTimeInterval.vue
Original file line number Diff line number Diff line change
@@ -1,55 +1,58 @@
<template lang="pug">
div
div
b-alert(v-if="mode == 'range' && invalidDaterange", variant="warning", show)
b-alert(v-if="invalidDaterange", variant="warning", show)
| The selected date range is invalid. The second date must be greater or equal to the first date.
b-alert(v-if="mode == 'range' && daterangeTooLong", variant="warning", show)
b-alert(v-if="daterangeTooLong", variant="warning", show)
| The selected date range is too long. The maximum is {{ maxDuration/(24*60*60) }} days.

div.d-flex.justify-content-between.align-items-end
div.d-flex.justify-content-between
table
tr
th.pr-2
label(for="mode") Interval mode:
td
select(id="mode", v-model="mode")
option(value='last_duration') Last duration
option(value='range') Date range
tr(v-if="mode == 'last_duration'")
th.pr-2
label(for="duration") Show last:
td
select(id="duration", v-model="duration", @change="valueChanged")
option(:value="15*60") 15min
option(:value="30*60") 30min
option(:value="60*60") 1h
option(:value="2*60*60") 2h
option(:value="4*60*60") 4h
option(:value="6*60*60") 6h
option(:value="12*60*60") 12h
option(:value="24*60*60") 24h
tr(v-if="mode == 'range'")
th.pr-2 Range:
td.pr-2
label.col-form-label.col-form-label-sm Show last
td(colspan=2)
.btn-group(role="group")
template(v-for="(dur, idx) in durations")
input(
type="radio"
:id="'dur' + idx"
:value="dur.seconds"
v-model="duration"
@change="applyLastDuration"
).d-none
label(:for="'dur' + idx" v-html="dur.label").btn.btn-light.btn-sm

tr
td.pr-2
label.col-form-label.col-form-label-sm Show from
td
input(type="date", v-model="start")
input(type="date", v-model="end")
button(
class="btn btn-outline-dark btn-sm",
input.form-control.form-control-sm.d-inline-block.p-1(type="date", v-model="start", style="height: auto; width: auto;")
label.col-form-label.col-form-label-sm.px-2 to
input.form-control.form-control-sm.d-inline.p-1(type="date", v-model="end", style="height: auto; width: auto")
td.text-right
button.ml-2.btn.btn-outline-dark.btn-sm(
type="button",
:disabled="mode == 'range' && (invalidDaterange || emptyDaterange || daterangeTooLong)",
@click="valueChanged"
) Update
:disabled="invalidDaterange || emptyDaterange || daterangeTooLong",
@click="applyRange"
) Apply

div(style="text-align:right" v-if="showUpdate && mode=='last_duration'")
b-button.px-2(@click="update()", variant="outline-dark", size="sm")
div.text-muted.d-none.d-md-block(style="text-align:right" v-if="showUpdate")
b-button.mt-2.px-2(@click="refresh()", variant="outline-dark", size="sm", style="opacity: 0.7")
icon(name="sync")
span.d-none.d-md-inline
| Update
div.mt-1.small.text-muted(v-if="lastUpdate")
| Refresh
div.mt-2.small(v-if="lastUpdate")
| Last update: #[time(:datetime="lastUpdate.format()") {{lastUpdate | friendlytime}}]
</template>

<style scoped lang="scss"></style>
<style scoped lang="scss">
.btn-group {
input[type='radio']:checked + label {
background-color: #aaa;
}
}
</style>

<script lang="ts">
import moment from 'moment';
Expand Down Expand Up @@ -77,6 +80,18 @@ export default {
start: null,
end: null,
lastUpdate: null,
durations: [
{ seconds: 0.25 * 60 * 60, label: '&frac14;h' },
{ seconds: 0.5 * 60 * 60, label: '&frac12;h' },
{ seconds: 60 * 60, label: '1h' },
{ seconds: 2 * 60 * 60, label: '2h' },
{ seconds: 3 * 60 * 60, label: '3h' },
{ seconds: 4 * 60 * 60, label: '4h' },
{ seconds: 6 * 60 * 60, label: '6h' },
{ seconds: 12 * 60 * 60, label: '12h' },
{ seconds: 24 * 60 * 60, label: '24h' },
{ seconds: 48 * 60 * 60, label: '48h' },
],
};
},
computed: {
Expand All @@ -103,13 +118,13 @@ export default {
this.duration = this.defaultDuration;
this.valueChanged();
// We want our lastUpdated text to update every ~3s
// We want our lastUpdated text to update every ~500ms
// We can do this by setting it to null and then the previous value.
this.lastUpdateTimer = setInterval(() => {
const _lastUpdate = this.lastUpdate;
this.lastUpdate = null;
this.lastUpdate = _lastUpdate;
}, 1000);
}, 500);
},
beforeDestroy() {
clearInterval(this.lastUpdateTimer);
Expand All @@ -124,12 +139,20 @@ export default {
this.$emit('input', this.value);
}
},
update() {
if (this.mode == 'last_duration') {
this.mode = ''; // remove cache on v-model, see explanation: https://github.com/ActivityWatch/aw-webui/pull/344/files#r892982094
this.mode = 'last_duration';
this.valueChanged();
}
refresh() {
const tmpMode = this.mode;
this.mode = '';
this.mode = tmpMode;
this.valueChanged();
},
applyRange() {
this.mode = 'range';
this.duration = 0;
this.valueChanged();
},
applyLastDuration() {
this.mode = 'last_duration';
this.valueChanged();
},
},
};
Expand Down
11 changes: 7 additions & 4 deletions src/views/Timeline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@
div
h2 Timeline

input-timeinterval(v-model="daterange", :defaultDuration="timeintervalDefaultDuration", :maxDuration="maxDuration").mb-2
input-timeinterval(v-model="daterange", :defaultDuration="timeintervalDefaultDuration", :maxDuration="maxDuration").mb-3

// blocks
div.d-inline-block.border.rounded.p-2.mr-2
| Events shown: {{ num_events }}
details.d-inline-block.bg-light.small.border.rounded.mr-2.px-2
summary.p-2
b Filters
Expand All @@ -26,7 +24,11 @@ div
select(v-model="filter_client")
option(:value='null') All
option(v-for="client in clients", :value="client") {{ client }}
div(style="float: right; color: #999").d-inline-block.pt-3
div.d-inline-block.border.rounded.p-2.mr-2(v-if="num_events !== 0")
| Events shown: {{ num_events }}
b-alert.d-inline-block.p-2.mb-0.mt-2(v-if="num_events === 0", variant="warning", show)
| No events match selected criteria. Timeline is not updated.
div.float-right.small.text-muted.pt-3
| Drag to pan and scroll to zoom

div(v-if="buckets !== null")
Expand Down Expand Up @@ -63,6 +65,7 @@ export default {
const settingsStore = useSettingsStore();
return Number(settingsStore.durationDefault);
},
// This does not match the chartData which is rendered in the timeline, as chartData excludes short events.
num_events() {
return _.sumBy(this.buckets, 'events.length');
},
Expand Down
2 changes: 1 addition & 1 deletion src/visualizations/VisTimeline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
div
div#visualization

div.small.my-2(v-if="bucketsFromEither.length != 1")
div.small.text-muted.my-2(v-if="bucketsFromEither.length != 1")
i Buckets with no events in the queried range will be hidden.

div(v-if="editingEvent")
Expand Down
10 changes: 3 additions & 7 deletions test/e2e/screenshot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,6 @@ test.clientScripts({

fixture(`Timeline view`).page(`${baseURL}/#/timeline`).requestHooks(HTTPLogger);

const durationSelect = Selector('select#duration');
const durationOption = durationSelect.find('option');

test.clientScripts({
content: logJsErrorCode,
})('Screenshot the timeline view', async t => {
Expand All @@ -134,10 +131,9 @@ test.clientScripts({
});
await waitForLoading(t);
await t
.click(durationSelect)
.click(durationOption.withText('12h'))
.expect(durationSelect.value)
.eql('43200');
.click(Selector('label').withText('12h'))
.expect(Selector('input[value="43200"]').checked)
.eql(true);

await t.takeScreenshot({
path: 'timeline.png',
Expand Down

1 comment on commit 291da6f

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are screenshots of this commit:

Screenshots using aw-server v0.12.3b18 (click to expand)

Screenshots using aw-server-rust master (click to expand)

Screenshots using aw-server-rust v0.12.3b18 (click to expand)

Please sign in to comment.