From 12a78d6817dbb43ba87b1721e51759ba6db0fe59 Mon Sep 17 00:00:00 2001 From: pfw_poonam Date: Thu, 30 Oct 2025 17:35:09 -0400 Subject: [PATCH 1/5] Added feature to select hours --- src/views/activity/Activity.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/src/views/activity/Activity.vue b/src/views/activity/Activity.vue index c7ca7368..30bd60d7 100644 --- a/src/views/activity/Activity.vue +++ b/src/views/activity/Activity.vue @@ -233,6 +233,7 @@ export default { periodLengths: function () { const settingsStore = useSettingsStore(); let periods: Record = { + hour: 'hour', day: 'day', week: 'week', month: 'month', From 43c1ba0a3c9727a2fcc1e6f2f134893f423053d3 Mon Sep 17 00:00:00 2001 From: pfw_poonam Date: Thu, 30 Oct 2025 19:38:33 -0400 Subject: [PATCH 2/5] feat: add time selection for hourly period in Activity view --- src/views/activity/Activity.vue | 38 +++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/views/activity/Activity.vue b/src/views/activity/Activity.vue index 30bd60d7..b706db0d 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 @@ -196,6 +208,8 @@ export default { viewsStore: useViewsStore(), settingsStore: useSettingsStore(), + startTime: '13:00', + endTime: '14:00', today: null, showOptions: false, @@ -249,7 +263,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,7 +298,18 @@ export default { timeperiod: function () { const settingsStore = useSettingsStore(); - if (this.periodIsBrowseable) { + if (this.periodIsBrowseable) { + if (this.periodLength === 'hour') { + const startDateTime = moment(`${this._date} ${this.startTime}`, 'YYYY-MM-DD HH:mm'); + const endDateTime = moment(`${this._date} ${this.endTime}`, 'YYYY-MM-DD HH:mm'); + const durationHours = endDateTime.diff(startDateTime, 'hours', true); + + return { + start: startDateTime, + length: [durationHours, 'hours'], + }; + } + return { start: get_day_start_with_offset(this._date, settingsStore.startOfDay), length: [1, this.periodLength], @@ -478,6 +503,15 @@ export default { name: '', }; }, + setStartTime: function(time) { + this.startTime = time; + this.refresh(); + }, + + setEndTime: function(time) { + this.endTime = time; + this.refresh(); + } }, }; From f4b9ae0cc2a72c02aaaf48fa50dc4493a9b59731 Mon Sep 17 00:00:00 2001 From: pfw_poonam Date: Fri, 31 Oct 2025 13:59:08 -0400 Subject: [PATCH 3/5] feat: enhance time period handling for hourly queries in Activity view --- src/stores/activity.ts | 9 +++++++-- src/views/activity/Activity.vue | 2 +- src/visualizations/TimelineBarChart.vue | 8 +++++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/stores/activity.ts b/src/stores/activity.ts index 6a277467..ae0f532d 100644 --- a/src/stores/activity.ts +++ b/src/stores/activity.ts @@ -443,7 +443,12 @@ export const useActivityStore = defineStore('activity', { let periods: string[]; const count = timeperiod.length[0]; const res = timeperiod.length[1]; - if (res.startsWith('day') && count == 1) { + + // ADDED: Handle hourly timeperiods + if (res.startsWith('hour') && count == 1) { + // If timeperiod is a single hour, just query that hour (no breakdown) + periods = [timeperiodToStr(timeperiod)]; + } else if (res.startsWith('day') && count == 1) { // If timeperiod is a single day, we query the individual hours periods = timeperiodsStrsHoursOfPeriod(timeperiod); } else if ( @@ -750,4 +755,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 b706db0d..a2a155e4 100644 --- a/src/views/activity/Activity.vue +++ b/src/views/activity/Activity.vue @@ -305,7 +305,7 @@ export default { const durationHours = endDateTime.diff(startDateTime, 'hours', true); return { - start: startDateTime, + start: startDateTime.format(), length: [durationHours, 'hours'], }; } diff --git a/src/visualizations/TimelineBarChart.vue b/src/visualizations/TimelineBarChart.vue index 7481c0f5..4ec8f13d 100644 --- a/src/visualizations/TimelineBarChart.vue +++ b/src/visualizations/TimelineBarChart.vue @@ -56,7 +56,13 @@ export default { labels() { const start = this.timeperiod_start; const [count, resolution] = this.timeperiod_length; - if (resolution.startsWith('day') && count == 1) { + if (resolution.startsWith('hour') && count == 1) { + // For a single hour, just show the hour label + const date = new Date(start); + const hour = date.getHours(); + const minute = date.getMinutes(); + return [`${hour}:${minute.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')) { From 5e0654e18fa3cbba95b42b024d75c48c7a1311d7 Mon Sep 17 00:00:00 2001 From: pfw_poonam Date: Fri, 31 Oct 2025 17:53:59 -0400 Subject: [PATCH 4/5] feat: enhance hourly time period handling in Activity view and visualizations --- src/stores/activity.ts | 192 ++++++++++++------------ src/views/activity/Activity.vue | 103 +++++++++---- src/visualizations/TimelineBarChart.vue | 45 ++++-- 3 files changed, 200 insertions(+), 140 deletions(-) diff --git a/src/stores/activity.ts b/src/stores/activity.ts index ae0f532d..d751e0fa 100644 --- a/src/stores/activity.ts +++ b/src/stores/activity.ts @@ -432,112 +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]; - - // ADDED: Handle hourly timeperiods - if (res.startsWith('hour') && count == 1) { - // If timeperiod is a single hour, just query that hour (no breakdown) - periods = [timeperiodToStr(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}`); + 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 = [timeperiodToStr(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 => { diff --git a/src/views/activity/Activity.vue b/src/views/activity/Activity.vue index a2a155e4..b954053a 100644 --- a/src/views/activity/Activity.vue +++ b/src/views/activity/Activity.vue @@ -190,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, @@ -208,8 +203,6 @@ export default { viewsStore: useViewsStore(), settingsStore: useSettingsStore(), - startTime: '13:00', - endTime: '14:00', today: null, showOptions: false, @@ -224,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); @@ -298,17 +314,31 @@ export default { timeperiod: function () { const settingsStore = useSettingsStore(); - if (this.periodIsBrowseable) { - if (this.periodLength === 'hour') { - const startDateTime = moment(`${this._date} ${this.startTime}`, 'YYYY-MM-DD HH:mm'); - const endDateTime = moment(`${this._date} ${this.endTime}`, 'YYYY-MM-DD HH:mm'); - const durationHours = endDateTime.diff(startDateTime, 'hours', true); - - return { - start: startDateTime.format(), - length: [durationHours, 'hours'], - }; - } + 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), @@ -329,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') { @@ -390,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], @@ -398,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], @@ -503,15 +541,14 @@ export default { name: '', }; }, - setStartTime: function(time) { - this.startTime = time; - this.refresh(); - }, - - setEndTime: function(time) { - this.endTime = time; - this.refresh(); - } + + 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 4ec8f13d..1f182f52 100644 --- a/src/visualizations/TimelineBarChart.vue +++ b/src/visualizations/TimelineBarChart.vue @@ -56,12 +56,23 @@ export default { labels() { const start = this.timeperiod_start; const [count, resolution] = this.timeperiod_length; - if (resolution.startsWith('hour') && count == 1) { - // For a single hour, just show the hour label - const date = new Date(start); - const hour = date.getHours(); - const minute = date.getMinutes(); - return [`${hour}:${minute.toString().padStart(2, '0')}`]; + + // Handle custom hour ranges (including fractional hours like 1.5) + if (resolution.startsWith('hour')) { + // For custom hour ranges, show start time and end time + const startDate = new Date(start); + const startHour = startDate.getHours(); + const startMin = startDate.getMinutes(); + + // Calculate end time + const endDate = new Date(startDate.getTime() + count * 60 * 60 * 1000); + const endHour = endDate.getHours(); + const endMin = endDate.getMinutes(); + + const formatTime = (h, m) => `${h}:${m.toString().padStart(2, '0')}`; + + // Return a single label showing the time range + return [`${formatTime(startHour, startMin)} - ${formatTime(endHour, endMin)}`]; } else if (resolution.startsWith('day') && count == 1) { const hourOffset = get_hour_offset(); return _.range(0, 24).map(h => `${(h + hourOffset) % 24}`); @@ -101,6 +112,22 @@ export default { }, chartOptions(): ChartOptions { const resolution = this.timeperiod_length[1]; + const count = this.timeperiod_length[0]; + + // For custom hour ranges, set appropriate y-axis max + let suggestedMax = undefined; + let stepSize = 1; + + if (resolution.startsWith('hour')) { + // For hour ranges, max should be the duration + suggestedMax = count; + // Use smaller step size for fractional hours + stepSize = count < 2 ? 0.25 : 0.5; + } else if (resolution.startsWith('day')) { + suggestedMax = 1; + stepSize = 0.25; + } + return { plugins: { tooltip: { @@ -118,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, }, }, }, @@ -131,4 +158,4 @@ export default { }; - + \ No newline at end of file From eb7026593dbb5a510ec3c94d47842ff4b1f8f97d Mon Sep 17 00:00:00 2001 From: pfw_poonam Date: Mon, 3 Nov 2025 16:55:42 -0500 Subject: [PATCH 5/5] feat: improve hour range handling in TimelineBarChart visualization --- src/stores/activity.ts | 2 +- src/visualizations/TimelineBarChart.vue | 36 ++++++++++++------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/stores/activity.ts b/src/stores/activity.ts index d751e0fa..539e89b2 100644 --- a/src/stores/activity.ts +++ b/src/stores/activity.ts @@ -447,7 +447,7 @@ export const useActivityStore = defineStore('activity', { if (res.startsWith('hour')) { // For hour periods (including custom ranges like 1.5 hours), // just query the exact timeperiod as a single period - periods = [timeperiodToStr(timeperiod)]; + periods = timeperiodsStrsHoursOfPeriod(timeperiod); } else if (res.startsWith('day') && count == 1) { // If timeperiod is a single day, we query the individual hours periods = timeperiodsStrsHoursOfPeriod(timeperiod); diff --git a/src/visualizations/TimelineBarChart.vue b/src/visualizations/TimelineBarChart.vue index 1f182f52..84d565ba 100644 --- a/src/visualizations/TimelineBarChart.vue +++ b/src/visualizations/TimelineBarChart.vue @@ -57,22 +57,23 @@ export default { const start = this.timeperiod_start; const [count, resolution] = this.timeperiod_length; - // Handle custom hour ranges (including fractional hours like 1.5) + // Handle hour ranges - show individual hour labels if (resolution.startsWith('hour')) { - // For custom hour ranges, show start time and end time const startDate = new Date(start); - const startHour = startDate.getHours(); - const startMin = startDate.getMinutes(); + const numHours = Math.ceil(count); - // Calculate end time - const endDate = new Date(startDate.getTime() + count * 60 * 60 * 1000); - const endHour = endDate.getHours(); - const endMin = endDate.getMinutes(); - - const formatTime = (h, m) => `${h}:${m.toString().padStart(2, '0')}`; - - // Return a single label showing the time range - return [`${formatTime(startHour, startMin)} - ${formatTime(endHour, endMin)}`]; + 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}`); @@ -114,15 +115,14 @@ export default { const resolution = this.timeperiod_length[1]; const count = this.timeperiod_length[0]; - // For custom hour ranges, set appropriate y-axis max + // Set appropriate y-axis max based on resolution let suggestedMax = undefined; let stepSize = 1; if (resolution.startsWith('hour')) { - // For hour ranges, max should be the duration - suggestedMax = count; - // Use smaller step size for fractional hours - stepSize = count < 2 ? 0.25 : 0.5; + // 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;