Skip to content

Commit 8274a00

Browse files
committed
feat: added workreport feature (wip)
1 parent 60706f1 commit 8274a00

3 files changed

Lines changed: 345 additions & 0 deletions

File tree

src/components/Header.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ div(:class="{'fixed-top-padding': fixedTopMenu}")
5959
b-dropdown-item(to="/search")
6060
icon(name="search")
6161
| Search
62+
b-dropdown-item(to="/work-report")
63+
icon(name="briefcase")
64+
| Work Report
6265
b-dropdown-item(to="/trends" v-if="devmode")
6366
icon(name="chart-line")
6467
| Trends
@@ -98,6 +101,7 @@ div(:class="{'fixed-top-padding': fixedTopMenu}")
98101
<script lang="ts">
99102
// only import the icons you use to reduce bundle size
100103
import 'vue-awesome/icons/calendar-day';
104+
import 'vue-awesome/icons/briefcase';
101105
import 'vue-awesome/icons/calendar-week';
102106
import 'vue-awesome/icons/stream';
103107
import 'vue-awesome/icons/database';

src/route.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const Trends = () => import('./views/Trends.vue');
1515
const Settings = () => import('./views/settings/Settings.vue');
1616
const CategoryBuilder = () => import('./views/settings/CategoryBuilder.vue');
1717
const Stopwatch = () => import('./views/Stopwatch.vue');
18+
const WorkReport = () => import('./views/WorkReport.vue');
1819
const Alerts = () => import('./views/Alerts.vue');
1920
const Search = () => import('./views/Search.vue');
2021
const Report = () => import('./views/Report.vue');
@@ -66,6 +67,7 @@ const router = new VueRouter({
6667
{ path: '/settings', component: Settings },
6768
{ path: '/settings/category-builder', component: CategoryBuilder },
6869
{ path: '/stopwatch', component: Stopwatch },
70+
{ path: '/work-report', component: WorkReport },
6971
{ path: '/search', component: Search },
7072
{ path: '/graph', component: Graph },
7173
{ path: '/dev', component: Dev },

src/views/WorkReport.vue

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
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

Comments
 (0)