|
| 1 | +<template lang="pug"> |
| 2 | +div |
| 3 | + h3.mb-3 Work Time Report |
| 4 | + |
| 5 | + div.row.mb-4 |
| 6 | + div.col-md-3 |
| 7 | + b-form-group(label="Hosts" label-class="font-weight-bold") |
| 8 | + b-form-select(v-model="selectedHosts" :options="hostOptions" multiple :select-size="4") |
| 9 | + small.text-muted Select devices to include |
| 10 | + |
| 11 | + div.col-md-3 |
| 12 | + b-form-group(label="Categories" label-class="font-weight-bold") |
| 13 | + b-form-select(v-model="selectedCategories" :options="categoryOptions" multiple :select-size="3") |
| 14 | + |
| 15 | + div.col-md-3 |
| 16 | + b-form-group(label="Break Time" label-class="font-weight-bold") |
| 17 | + div.d-flex.align-items-center |
| 18 | + b-form-input( |
| 19 | + v-model="breakTime" |
| 20 | + type="range" |
| 21 | + min="0" |
| 22 | + max="30" |
| 23 | + step="1" |
| 24 | + ) |
| 25 | + span.ml-2.text-nowrap {{ breakTime }} min |
| 26 | + small.text-muted Gaps shorter than this will be counted as work time |
| 27 | + |
| 28 | + div.col-md-3 |
| 29 | + b-form-group(label="Date Range" label-class="font-weight-bold") |
| 30 | + b-form-select(v-model="dateRange" :options="dateRangeOptions") |
| 31 | + small.text-muted(v-if="dateRange === 'custom'") |
| 32 | + | Custom range: {{ customStart }} to {{ customEnd }} |
| 33 | + |
| 34 | + div.mb-3 |
| 35 | + b-button(@click="loadData" variant="primary") |
| 36 | + icon(name="sync") |
| 37 | + | Calculate Work Time |
| 38 | + b-button.ml-2(@click="exportCSV" variant="outline-secondary" :disabled="!hasData") |
| 39 | + icon(name="download") |
| 40 | + | Export CSV |
| 41 | + b-button.ml-2(@click="exportJSON" variant="outline-secondary" :disabled="!hasData") |
| 42 | + icon(name="download") |
| 43 | + | Export JSON |
| 44 | + |
| 45 | + div(v-if="loading") |
| 46 | + b-spinner.mr-2 |
| 47 | + | Loading... |
| 48 | + |
| 49 | + div(v-if="hasData && !loading") |
| 50 | + h5.mt-4 Daily Breakdown |
| 51 | + |
| 52 | + table.table.table-sm.table-hover |
| 53 | + thead |
| 54 | + tr |
| 55 | + th Date |
| 56 | + th.text-right Work Time |
| 57 | + th.text-right Sessions |
| 58 | + th.text-right Avg Session |
| 59 | + tbody |
| 60 | + tr(v-for="day in dailyData" :key="day.date") |
| 61 | + td {{ day.date }} |
| 62 | + td.text-right {{ formatDuration(day.duration) }} |
| 63 | + td.text-right {{ day.sessions }} |
| 64 | + td.text-right {{ formatDuration(day.avgSession) }} |
| 65 | + tfoot |
| 66 | + tr.font-weight-bold |
| 67 | + td Total |
| 68 | + td.text-right {{ formatDuration(totalDuration) }} |
| 69 | + td.text-right {{ totalSessions }} |
| 70 | + td.text-right {{ formatDuration(avgSessionLength) }} |
| 71 | + |
| 72 | +</template> |
| 73 | + |
| 74 | +<script lang="ts"> |
| 75 | +import moment from 'moment'; |
| 76 | +import { getClient } from '~/util/awclient'; |
| 77 | +import { useCategoryStore } from '~/stores/categories'; |
| 78 | +import { useSettingsStore } from '~/stores/settings'; |
| 79 | +import { useBucketsStore } from '~/stores/buckets'; |
| 80 | +
|
| 81 | +import 'vue-awesome/icons/sync'; |
| 82 | +import 'vue-awesome/icons/download'; |
| 83 | +
|
| 84 | +interface DailyData { |
| 85 | + date: string; |
| 86 | + duration: number; |
| 87 | + sessions: number; |
| 88 | + avgSession: number; |
| 89 | + events: any[]; |
| 90 | +} |
| 91 | +
|
| 92 | +export default { |
| 93 | + name: 'WorkReport', |
| 94 | + data() { |
| 95 | + return { |
| 96 | + categoryStore: useCategoryStore(), |
| 97 | + settingsStore: useSettingsStore(), |
| 98 | + bucketsStore: useBucketsStore(), |
| 99 | + |
| 100 | + selectedHosts: [], // Will be populated on mount |
| 101 | + selectedCategories: [JSON.stringify(['Work'])], // Default to 'Work' category |
| 102 | + breakTime: 5, |
| 103 | + dateRange: 'last7d', |
| 104 | + customStart: null, |
| 105 | + customEnd: null, |
| 106 | + |
| 107 | + loading: false, |
| 108 | + dailyData: [] as DailyData[], |
| 109 | + rawData: {}, |
| 110 | + }; |
| 111 | + }, |
| 112 | + computed: { |
| 113 | + hostOptions() { |
| 114 | + // Get available hosts from window watcher buckets |
| 115 | + const allBuckets = this.bucketsStore.buckets || []; |
| 116 | + const windowBuckets = allBuckets.filter(b => b.type === 'currentwindow'); |
| 117 | + |
| 118 | + const hosts = windowBuckets.map(b => { |
| 119 | + // Extract hostname from bucket ID (format: aw-watcher-window_hostname) |
| 120 | + return b.id.replace('aw-watcher-window_', ''); |
| 121 | + }); |
| 122 | + |
| 123 | + return hosts.map(host => ({ |
| 124 | + value: host, |
| 125 | + text: host, |
| 126 | + })); |
| 127 | + }, |
| 128 | + |
| 129 | + categoryOptions() { |
| 130 | + // Get all categories (not just those with rules) for the filter |
| 131 | + const cats = this.categoryStore.all_categories || []; |
| 132 | + return cats.map(cat => ({ |
| 133 | + value: JSON.stringify(cat), // Store as JSON string to preserve array structure |
| 134 | + text: cat.join(' > '), |
| 135 | + })); |
| 136 | + }, |
| 137 | + dateRangeOptions() { |
| 138 | + return [ |
| 139 | + { value: 'last7d', text: 'Last 7 days' }, |
| 140 | + { value: 'last30d', text: 'Last 30 days' }, |
| 141 | + { value: 'thisWeek', text: 'This week' }, |
| 142 | + { value: 'thisMonth', text: 'This month' }, |
| 143 | + { value: 'custom', text: 'Custom range' }, |
| 144 | + ]; |
| 145 | + }, |
| 146 | + hasData() { |
| 147 | + return this.dailyData.length > 0; |
| 148 | + }, |
| 149 | + totalDuration() { |
| 150 | + return this.dailyData.reduce((sum, day) => sum + day.duration, 0); |
| 151 | + }, |
| 152 | + totalSessions() { |
| 153 | + return this.dailyData.reduce((sum, day) => sum + day.sessions, 0); |
| 154 | + }, |
| 155 | + avgSessionLength() { |
| 156 | + return this.totalSessions > 0 ? this.totalDuration / this.totalSessions : 0; |
| 157 | + }, |
| 158 | + }, |
| 159 | + async mounted() { |
| 160 | + this.categoryStore.load(); |
| 161 | + await this.bucketsStore.ensureLoaded(); |
| 162 | + |
| 163 | + // Auto-select all available hosts |
| 164 | + if (this.hostOptions.length > 0) { |
| 165 | + this.selectedHosts = this.hostOptions.map(opt => opt.value); |
| 166 | + } |
| 167 | + }, |
| 168 | + methods: { |
| 169 | + async loadData() { |
| 170 | + this.loading = true; |
| 171 | + try { |
| 172 | + const client = getClient(); |
| 173 | + |
| 174 | + if (this.selectedHosts.length === 0) { |
| 175 | + alert('Please select at least one host'); |
| 176 | + this.loading = false; |
| 177 | + return; |
| 178 | + } |
| 179 | + |
| 180 | + // Get date range |
| 181 | + const timeperiods = this.getTimeperiods(); |
| 182 | + |
| 183 | + // Build query with flooding |
| 184 | + const breakTimeSeconds = this.breakTime * 60; |
| 185 | + // Parse categories back from JSON strings |
| 186 | + const categoriesFilter = this.selectedCategories.map(c => JSON.parse(c)); |
| 187 | + |
| 188 | + // Get categories for query |
| 189 | + const categories = this.categoryStore.classes_for_query; |
| 190 | + const categoriesStr = JSON.stringify(categories).replace(/\\\\/g, '\\'); |
| 191 | + |
| 192 | + // Build multi-device query with custom flooding |
| 193 | + let query = ''; |
| 194 | + |
| 195 | + // Query each host with custom flooding |
| 196 | + for (const hostname of this.selectedHosts) { |
| 197 | + const safeHost = hostname.replace(/[^a-zA-Z0-9_]/g, ''); |
| 198 | + query += ` |
| 199 | + events_${safeHost} = flood(query_bucket(find_bucket("aw-watcher-window_${hostname}")), ${breakTimeSeconds}); |
| 200 | + events_${safeHost} = categorize(events_${safeHost}, ${categoriesStr}); |
| 201 | + events_${safeHost} = filter_keyvals(events_${safeHost}, "$category", ${JSON.stringify(categoriesFilter)}); |
| 202 | + `; |
| 203 | + } |
| 204 | + |
| 205 | + // Combine events from all hosts using union_no_overlap |
| 206 | + query += '\nevents = [];'; |
| 207 | + for (const hostname of this.selectedHosts) { |
| 208 | + const safeHost = hostname.replace(/[^a-zA-Z0-9_]/g, ''); |
| 209 | + query += `\nevents = union_no_overlap(events, events_${safeHost});`; |
| 210 | + } |
| 211 | + |
| 212 | + query += ` |
| 213 | + duration = sum_durations(events); |
| 214 | + RETURN = {"events": events, "duration": duration}; |
| 215 | + `; |
| 216 | + |
| 217 | + // Debug: log the query |
| 218 | + console.log('Query being sent:', query); |
| 219 | + console.log('Query length:', query.length); |
| 220 | + |
| 221 | + // Query for each day |
| 222 | + const results = await client.query(timeperiods, [query]); |
| 223 | + |
| 224 | + // Process results into daily data |
| 225 | + this.dailyData = timeperiods.map((tp, i) => { |
| 226 | + const result = results[i]; |
| 227 | + const events = result.events || []; |
| 228 | + const duration = result.duration || 0; |
| 229 | + |
| 230 | + // tp is a string like "2025-01-01T00:00:00Z/2025-01-02T00:00:00Z" |
| 231 | + const startDate = tp.split('/')[0]; |
| 232 | + |
| 233 | + return { |
| 234 | + date: moment(startDate).format('YYYY-MM-DD'), |
| 235 | + duration, |
| 236 | + sessions: events.length, |
| 237 | + avgSession: events.length > 0 ? duration / events.length : 0, |
| 238 | + events, |
| 239 | + }; |
| 240 | + }); |
| 241 | + |
| 242 | + this.rawData = results; |
| 243 | + } catch (error) { |
| 244 | + console.error('Error loading work time data:', error); |
| 245 | + alert('Error loading data. See console for details.'); |
| 246 | + } finally { |
| 247 | + this.loading = false; |
| 248 | + } |
| 249 | + }, |
| 250 | + |
| 251 | + getTimeperiods() { |
| 252 | + const offset = this.settingsStore.startOfDay; |
| 253 | + |
| 254 | + const timeperiods = []; |
| 255 | + |
| 256 | + if (this.dateRange === 'last7d') { |
| 257 | + for (let i = 6; i >= 0; i--) { |
| 258 | + const start = moment().subtract(i, 'days').startOf('day').add(offset); |
| 259 | + const end = start.clone().add(1, 'day'); |
| 260 | + timeperiods.push(start.format() + '/' + end.format()); |
| 261 | + } |
| 262 | + } else if (this.dateRange === 'last30d') { |
| 263 | + for (let i = 29; i >= 0; i--) { |
| 264 | + const start = moment().subtract(i, 'days').startOf('day').add(offset); |
| 265 | + const end = start.clone().add(1, 'day'); |
| 266 | + timeperiods.push(start.format() + '/' + end.format()); |
| 267 | + } |
| 268 | + } |
| 269 | + // TODO: Add other date ranges |
| 270 | + |
| 271 | + return timeperiods; |
| 272 | + }, |
| 273 | + |
| 274 | + formatDuration(seconds: number): string { |
| 275 | + if (!seconds) return '0:00'; |
| 276 | + const hours = Math.floor(seconds / 3600); |
| 277 | + const minutes = Math.floor((seconds % 3600) / 60); |
| 278 | + return `${hours}:${minutes.toString().padStart(2, '0')}`; |
| 279 | + }, |
| 280 | + |
| 281 | + exportCSV() { |
| 282 | + const headers = ['Date', 'Duration (hours)', 'Sessions', 'Avg Session (minutes)']; |
| 283 | + const rows = this.dailyData.map(day => [ |
| 284 | + day.date, |
| 285 | + (day.duration / 3600).toFixed(2), |
| 286 | + day.sessions, |
| 287 | + (day.avgSession / 60).toFixed(1), |
| 288 | + ]); |
| 289 | + |
| 290 | + const csv = [ |
| 291 | + headers.join(','), |
| 292 | + ...rows.map(row => row.join(',')), |
| 293 | + '', |
| 294 | + `Total,${(this.totalDuration / 3600).toFixed(2)},${this.totalSessions},${(this.avgSessionLength / 60).toFixed(1)}`, |
| 295 | + ].join('\n'); |
| 296 | + |
| 297 | + this.downloadFile(csv, 'work_time_report.csv', 'text/csv'); |
| 298 | + }, |
| 299 | + |
| 300 | + exportJSON() { |
| 301 | + const data = { |
| 302 | + parameters: { |
| 303 | + categories: this.selectedCategories, |
| 304 | + breakTime: this.breakTime, |
| 305 | + dateRange: this.dateRange, |
| 306 | + }, |
| 307 | + summary: { |
| 308 | + totalDuration: this.totalDuration, |
| 309 | + totalSessions: this.totalSessions, |
| 310 | + avgSessionLength: this.avgSessionLength, |
| 311 | + }, |
| 312 | + daily: this.dailyData, |
| 313 | + rawEvents: this.rawData, |
| 314 | + }; |
| 315 | + |
| 316 | + const json = JSON.stringify(data, null, 2); |
| 317 | + this.downloadFile(json, 'work_time_report.json', 'application/json'); |
| 318 | + }, |
| 319 | + |
| 320 | + downloadFile(content: string, filename: string, mimeType: string) { |
| 321 | + const blob = new Blob([content], { type: mimeType }); |
| 322 | + const url = URL.createObjectURL(blob); |
| 323 | + const link = document.createElement('a'); |
| 324 | + link.href = url; |
| 325 | + link.download = filename; |
| 326 | + document.body.appendChild(link); |
| 327 | + link.click(); |
| 328 | + document.body.removeChild(link); |
| 329 | + URL.revokeObjectURL(url); |
| 330 | + }, |
| 331 | + }, |
| 332 | +}; |
| 333 | +</script> |
| 334 | + |
| 335 | +<style scoped> |
| 336 | +.table { |
| 337 | + font-size: 0.9rem; |
| 338 | +} |
| 339 | +</style> |
0 commit comments