diff --git a/src/stores/activity.ts b/src/stores/activity.ts index 6a277467..539e89b2 100644 --- a/src/stores/activity.ts +++ b/src/stores/activity.ts @@ -432,107 +432,108 @@ export const useActivityStore = defineStore('activity', { }, async query_category_time_by_period({ - timeperiod, - filter_categories, - filter_afk, - include_stopwatch, - dontQueryInactive, - always_active_pattern, - }: QueryOptions & { dontQueryInactive: boolean }) { - // TODO: Needs to be adapted for Android - let periods: string[]; - const count = timeperiod.length[0]; - const res = timeperiod.length[1]; - if (res.startsWith('day') && count == 1) { - // If timeperiod is a single day, we query the individual hours - periods = timeperiodsStrsHoursOfPeriod(timeperiod); - } else if ( - res.startsWith('day') || - (res.startsWith('week') && count == 1) || - (res.startsWith('month') && count == 1) - ) { - // If timeperiod is several days, or a single week/month, we query the individual days - periods = timeperiodsStrsDaysOfPeriod(timeperiod); - } else if (timeperiod.length[1].startsWith('year') && timeperiod.length[0] == 1) { - // If timeperiod a single year, we query the individual months - periods = timeperiodsStrsMonthsOfPeriod(timeperiod); - } else { - console.error(`Unknown timeperiod length: ${timeperiod.length}`); + timeperiod, + filter_categories, + filter_afk, + include_stopwatch, + dontQueryInactive, + always_active_pattern, + }: QueryOptions & { dontQueryInactive: boolean }) { + let periods: string[]; + const count = timeperiod.length[0]; + const res = timeperiod.length[1]; + + // Handle hourly timeperiods + if (res.startsWith('hour')) { + // For hour periods (including custom ranges like 1.5 hours), + // just query the exact timeperiod as a single period + periods = timeperiodsStrsHoursOfPeriod(timeperiod); + } else if (res.startsWith('day') && count == 1) { + // If timeperiod is a single day, we query the individual hours + periods = timeperiodsStrsHoursOfPeriod(timeperiod); + } else if ( + res.startsWith('day') || + (res.startsWith('week') && count == 1) || + (res.startsWith('month') && count == 1) + ) { + // If timeperiod is several days, or a single week/month, we query the individual days + periods = timeperiodsStrsDaysOfPeriod(timeperiod); + } else if (timeperiod.length[1].startsWith('year') && timeperiod.length[0] == 1) { + // If timeperiod a single year, we query the individual months + periods = timeperiodsStrsMonthsOfPeriod(timeperiod); + } else { + console.error(`Unknown timeperiod length: ${timeperiod.length}`); + } + + // Filter out periods that start in the future + periods = periods.filter(period => new Date(period.split('/')[0]) < new Date()); + + const signal = getClient().controller.signal; + let cancelled = false; + signal.onabort = () => { + cancelled = true; + console.debug('Request aborted'); + }; + + // Query one period at a time, to avoid timeout on slow queries + let data = []; + for (const period of periods) { + if (cancelled) { + throw signal['reason'] || 'unknown reason'; } - // Filter out periods that start in the future - periods = periods.filter(period => new Date(period.split('/')[0]) < new Date()); - - const signal = getClient().controller.signal; - let cancelled = false; - signal.onabort = () => { - cancelled = true; - console.debug('Request aborted'); - }; + // Only query periods with known data from AFK bucket + if (dontQueryInactive && this.active.events.length > 0) { + const start = new Date(period.split('/')[0]); + const end = new Date(period.split('/')[1]); - // Query one period at a time, to avoid timeout on slow queries - let data = []; - for (const period of periods) { - // Not stable - //signal.throwIfAborted(); - if (cancelled) { - throw signal['reason'] || 'unknown reason'; - } - - // Only query periods with known data from AFK bucket - if (dontQueryInactive && this.active.events.length > 0) { - const start = new Date(period.split('/')[0]); - const end = new Date(period.split('/')[1]); - - // Retrieve active time in period - const period_activity = this.active.events.find((e: IEvent) => { - return start < new Date(e.timestamp) && new Date(e.timestamp) < end; - }); + // Retrieve active time in period + const period_activity = this.active.events.find((e: IEvent) => { + return start < new Date(e.timestamp) && new Date(e.timestamp) < end; + }); - // Check if there was active time - if (!(period_activity && period_activity.duration > 0)) { - data = data.concat([{ cat_events: [] }]); - continue; - } + // Check if there was active time + if (!(period_activity && period_activity.duration > 0)) { + data = data.concat([{ cat_events: [] }]); + continue; } - - const isAndroid = this.buckets.android[0] !== undefined; - const categories = useCategoryStore().classes_for_query; - // TODO: Clean up call, pass QueryParams in fullDesktopQuery as well - // TODO: Unify QueryOptions and QueryParams - const query = queries.categoryQuery({ - bid_browsers: this.buckets.browser, - bid_stopwatch: - include_stopwatch && this.buckets.stopwatch.length > 0 - ? this.buckets.stopwatch[0] - : undefined, - categories, - filter_categories, - filter_afk, - always_active_pattern, - ...(isAndroid - ? { - bid_android: this.buckets.android[0], - } - : { - bid_afk: this.buckets.afk[0], - bid_window: this.buckets.window[0], - }), - }); - const result = await getClient().query([period], query, { - verbose: true, - name: 'categoryQuery', - }); - data = data.concat(result); } - // Zip periods - let by_period = _.zipObject(periods, data); - // Filter out values that are undefined (no longer needed, only used when visualization was progressive (looks buggy)) - by_period = _.fromPairs(_.toPairs(by_period).filter(o => o[1])); + const isAndroid = this.buckets.android[0] !== undefined; + const categories = useCategoryStore().classes_for_query; + const query = queries.categoryQuery({ + bid_browsers: this.buckets.browser, + bid_stopwatch: + include_stopwatch && this.buckets.stopwatch.length > 0 + ? this.buckets.stopwatch[0] + : undefined, + categories, + filter_categories, + filter_afk, + always_active_pattern, + ...(isAndroid + ? { + bid_android: this.buckets.android[0], + } + : { + bid_afk: this.buckets.afk[0], + bid_window: this.buckets.window[0], + }), + }); + const result = await getClient().query([period], query, { + verbose: true, + name: 'categoryQuery', + }); + data = data.concat(result); + } - this.query_category_time_by_period_completed({ by_period }); - }, + // Zip periods + let by_period = _.zipObject(periods, data); + // Filter out values that are undefined + by_period = _.fromPairs(_.toPairs(by_period).filter(o => o[1])); + + this.query_category_time_by_period_completed({ by_period }); + }, async query_active_history_android({ timeperiod }: QueryOptions) { const periods = timeperiodStrsAroundTimeperiod(timeperiod).filter(tp_str => { @@ -750,4 +751,4 @@ export const useActivityStore = defineStore('activity', { this.category.by_period = by_period; }, }, -}); +}); \ No newline at end of file diff --git a/src/views/activity/Activity.vue b/src/views/activity/Activity.vue index c7ca7368..b954053a 100644 --- a/src/views/activity/Activity.vue +++ b/src/views/activity/Activity.vue @@ -35,6 +35,18 @@ div div.mx-2(v-if="periodLength === 'day'") input.form-control.px-2(id="date" type="date" :value="_date" :max="today" @change="setDate($event.target.value, periodLength)") + + div.mx-2(v-if="periodLength === 'hour'") + input.form-control.px-2(id="date" type="date" :value="_date" :max="today" + @change="setDate($event.target.value, periodLength)") + div.mx-2(v-if="periodLength === 'hour'") + b-input-group + input.form-control.px-2(type="time" :value="startTime" + @change="setStartTime($event.target.value)") + b-input-group-text.px-2 to + input.form-control.px-2(type="time" :value="endTime" + @change="setEndTime($event.target.value)") + div.ml-auto b-button-group @@ -178,11 +190,6 @@ export default { host: String, date: { type: String, - // NOTE: This does not work as you'd might expect since the default is set on - // initialization, which would lead to the same date always being returned, - // even if the day has changed. - // Instead, use the computed _date. - //default: get_today(), }, periodLength: { type: String, @@ -210,6 +217,29 @@ export default { ...mapState(useSettingsStore, ['devmode']), ...mapState(useSettingsStore, ['always_active_pattern']), + // Get start and end times from query params, with defaults + startTime: { + get() { + return this.$route.query.start_time || '00:00'; + }, + set(value) { + this.$router.push({ + query: { ...this.$route.query, start_time: value } + }); + } + }, + + endTime: { + get() { + return this.$route.query.end_time || '23:59'; + }, + set(value) { + this.$router.push({ + query: { ...this.$route.query, end_time: value } + }); + } + }, + // number of filters currently set (different from defaults) filters_set() { return (this.filter_category ? 1 : 0) + (!this.filter_afk ? 1 : 0); @@ -233,6 +263,7 @@ export default { periodLengths: function () { const settingsStore = useSettingsStore(); let periods: Record = { + hour: 'hour', day: 'day', week: 'week', month: 'month', @@ -248,7 +279,7 @@ export default { return periods; }, periodIsBrowseable: function () { - return ['day', 'week', 'month', 'year'].includes(this.periodLength); + return ['hour', 'day', 'week', 'month', 'year'].includes(this.periodLength); }, currentView: function () { return this.views.find(v => v.id == this.$route.params.view_id) || this.views[0]; @@ -284,6 +315,31 @@ export default { const settingsStore = useSettingsStore(); if (this.periodIsBrowseable) { + if (this.periodLength === 'hour') { + // Parse start and end times + const [startHour, startMin] = this.startTime.split(':').map(Number); + const [endHour, endMin] = this.endTime.split(':').map(Number); + + // Create moment objects for start and end + const startDateTime = moment(this._date) + .startOf('day') + .add(startHour, 'hours') + .add(startMin, 'minutes'); + + const endDateTime = moment(this._date) + .startOf('day') + .add(endHour, 'hours') + .add(endMin, 'minutes'); + + // Calculate duration in hours (can be fractional) + const durationHours = endDateTime.diff(startDateTime, 'minutes') / 60; + + return { + start: startDateTime.format(), + length: [durationHours, 'hour'], // Use 'hour' (singular) to match TimelineBarChart + }; + } + return { start: get_day_start_with_offset(this._date, settingsStore.startOfDay), length: [1, this.periodLength], @@ -303,13 +359,13 @@ export default { const periodStart = moment(this.timeperiod.start); const dateFormatString = 'YYYY-MM-DD'; - // it's helpful to render a range for the week as opposed to just the start of the week - // or the number of the week so users can easily determine (a) if we are using monday/sunday as the week - // start and exactly when the week ends. The formatting code ends up being a bit more wonky, but it's - // worth the tradeoff. https://github.com/ActivityWatch/aw-webui/pull/284 - let periodLength; if (this.periodIsBrowseable) { + if (this.periodLength === 'hour') { + // For hour view, show the time range + const endTime = moment(periodStart).add(this.timeperiod.length[0], 'hours'); + return `${periodStart.format('YYYY-MM-DD HH:mm')} — ${endTime.format('HH:mm')}`; + } periodLength = [1, this.periodLength]; } else { if (this.periodLength === 'last7d') { @@ -364,6 +420,10 @@ export default { methods: { previousPeriod: function () { + if (this.periodLength === 'hour') { + // For hour view, go back by 1 day + return moment(this._date).subtract(1, 'day').format('YYYY-MM-DD'); + } return moment(this._date) .subtract( this.timeperiod.length[0], @@ -372,6 +432,10 @@ export default { .format('YYYY-MM-DD'); }, nextPeriod: function () { + if (this.periodLength === 'hour') { + // For hour view, go forward by 1 day + return moment(this._date).add(1, 'day').format('YYYY-MM-DD'); + } return moment(this._date) .add( this.timeperiod.length[0], @@ -477,6 +541,14 @@ export default { name: '', }; }, + + setStartTime: function(time) { + this.startTime = time; + }, + + setEndTime: function(time) { + this.endTime = time; + } }, }; - + \ No newline at end of file diff --git a/src/visualizations/TimelineBarChart.vue b/src/visualizations/TimelineBarChart.vue index 7481c0f5..84d565ba 100644 --- a/src/visualizations/TimelineBarChart.vue +++ b/src/visualizations/TimelineBarChart.vue @@ -56,7 +56,25 @@ export default { labels() { const start = this.timeperiod_start; const [count, resolution] = this.timeperiod_length; - if (resolution.startsWith('day') && count == 1) { + + // Handle hour ranges - show individual hour labels + if (resolution.startsWith('hour')) { + const startDate = new Date(start); + const numHours = Math.ceil(count); + + return _.range(numHours).map(h => { + const hourDate = new Date(startDate.getTime() + h * 60 * 60 * 1000); + const hours = hourDate.getHours(); + const minutes = hourDate.getMinutes(); + + // Format as HH:MM if there are minutes, otherwise just HH:00 + if (minutes === 0) { + return `${hours}:00`; + } else { + return `${hours}:${minutes.toString().padStart(2, '0')}`; + } + }); + } else if (resolution.startsWith('day') && count == 1) { const hourOffset = get_hour_offset(); return _.range(0, 24).map(h => `${(h + hourOffset) % 24}`); } else if (resolution.startsWith('day')) { @@ -95,6 +113,21 @@ export default { }, chartOptions(): ChartOptions { const resolution = this.timeperiod_length[1]; + const count = this.timeperiod_length[0]; + + // Set appropriate y-axis max based on resolution + let suggestedMax = undefined; + let stepSize = 1; + + if (resolution.startsWith('hour')) { + // For hour ranges, each bar represents 1 hour max + suggestedMax = 1; + stepSize = 0.25; // 15-minute increments + } else if (resolution.startsWith('day')) { + suggestedMax = 1; + stepSize = 0.25; + } + return { plugins: { tooltip: { @@ -112,10 +145,10 @@ export default { y: { stacked: true, min: 0, - suggestedMax: resolution.startsWith('day') ? 1 : undefined, + suggestedMax: suggestedMax, ticks: { callback: hourToTick, - stepSize: resolution.startsWith('day') ? 0.25 : 1, + stepSize: stepSize, }, }, }, @@ -125,4 +158,4 @@ export default { }; - + \ No newline at end of file