Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/components/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ div(:class="{'fixed-top-padding': fixedTopMenu}")
b-dropdown-item(to="/search")
icon(name="search")
| Search
b-dropdown-item(to="/work-report")
icon(name="briefcase")
| Work Report
b-dropdown-item(to="/trends" v-if="devmode")
icon(name="chart-line")
| Trends
Expand Down Expand Up @@ -98,6 +101,7 @@ div(:class="{'fixed-top-padding': fixedTopMenu}")
<script lang="ts">
// only import the icons you use to reduce bundle size
import 'vue-awesome/icons/calendar-day';
import 'vue-awesome/icons/briefcase';
import 'vue-awesome/icons/calendar-week';
import 'vue-awesome/icons/stream';
import 'vue-awesome/icons/database';
Expand Down
2 changes: 2 additions & 0 deletions src/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const Trends = () => import('./views/Trends.vue');
const Settings = () => import('./views/settings/Settings.vue');
const CategoryBuilder = () => import('./views/settings/CategoryBuilder.vue');
const Stopwatch = () => import('./views/Stopwatch.vue');
const WorkReport = () => import('./views/WorkReport.vue');
const Alerts = () => import('./views/Alerts.vue');
const Search = () => import('./views/Search.vue');
const Report = () => import('./views/Report.vue');
Expand Down Expand Up @@ -66,6 +67,7 @@ const router = new VueRouter({
{ path: '/settings', component: Settings },
{ path: '/settings/category-builder', component: CategoryBuilder },
{ path: '/stopwatch', component: Stopwatch },
{ path: '/work-report', component: WorkReport },
{ path: '/search', component: Search },
{ path: '/graph', component: Graph },
{ path: '/dev', component: Dev },
Expand Down
325 changes: 325 additions & 0 deletions src/views/WorkReport.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
<template lang="pug">
div
h3.mb-3 Work Time Report

div.row.mb-4
div.col-md-3
b-form-group(label="Hosts" label-class="font-weight-bold")
b-form-select(v-model="selectedHosts" :options="hostOptions" multiple :select-size="4")
small.text-muted Select devices to include

div.col-md-3
b-form-group(label="Categories" label-class="font-weight-bold")
b-form-select(v-model="selectedCategories" :options="categoryOptions" multiple :select-size="3")

div.col-md-3
b-form-group(label="Break Time" label-class="font-weight-bold")
div.d-flex.align-items-center
b-form-input(
v-model="breakTime"
type="range"
min="0"
max="30"
step="1"
)
span.ml-2.text-nowrap {{ breakTime }} min
small.text-muted Gaps shorter than this will be counted as work time

div.col-md-3
b-form-group(label="Date Range" label-class="font-weight-bold")
b-form-select(v-model="dateRange" :options="dateRangeOptions")

div.mb-3
b-button(@click="loadData" variant="primary")
icon(name="sync")
| Calculate Work Time
b-button.ml-2(@click="exportCSV" variant="outline-secondary" :disabled="!hasData")
icon(name="download")
| Export CSV
b-button.ml-2(@click="exportJSON" variant="outline-secondary" :disabled="!hasData")
icon(name="download")
| Export JSON

div(v-if="loading")
b-spinner.mr-2
| Loading...

div(v-if="hasData && !loading")
h5.mt-4 Daily Breakdown

table.table.table-sm.table-hover
thead
tr
th Date
th.text-right Work Time
th.text-right Sessions
th.text-right Avg Session
tbody
tr(v-for="day in dailyData" :key="day.date")
td {{ day.date }}
td.text-right {{ formatDuration(day.duration) }}
td.text-right {{ day.sessions }}
td.text-right {{ formatDuration(day.avgSession) }}
tfoot
tr.font-weight-bold
td Total
td.text-right {{ formatDuration(totalDuration) }}
td.text-right {{ totalSessions }}
td.text-right {{ formatDuration(avgSessionLength) }}

</template>

<script lang="ts">
import moment from 'moment';
import { getClient } from '~/util/awclient';
import { useCategoryStore } from '~/stores/categories';
import { useSettingsStore } from '~/stores/settings';
import { useBucketsStore } from '~/stores/buckets';

import 'vue-awesome/icons/sync';
import 'vue-awesome/icons/download';

interface DailyData {
date: string;
duration: number;
sessions: number;
avgSession: number;
events: any[];
}

export default {
name: 'WorkReport',
data() {
return {
categoryStore: useCategoryStore(),
settingsStore: useSettingsStore(),
bucketsStore: useBucketsStore(),

selectedHosts: [] as string[],
selectedCategories: [JSON.stringify(['Work'])],
breakTime: 5,
dateRange: 'last7d',

loading: false,
dailyData: [] as DailyData[],
rawData: {} as Record<string, any>,
};
},
computed: {
hostOptions() {
const allBuckets = this.bucketsStore.buckets || [];
const windowBuckets = allBuckets.filter(b => b.type === 'currentwindow');

const hosts = windowBuckets.map(b => {
return b.id.replace('aw-watcher-window_', '');
});

return hosts.map(host => ({
value: host,
text: host,
}));
},

categoryOptions() {
const cats = this.categoryStore.all_categories || [];
return cats.map(cat => ({
value: JSON.stringify(cat),
text: cat.join(' > '),
}));
},
dateRangeOptions() {
return [
{ value: 'last7d', text: 'Last 7 days' },
{ value: 'last30d', text: 'Last 30 days' },
{ value: 'thisWeek', text: 'This week' },
{ value: 'thisMonth', text: 'This month' },
];
},
hasData() {
return this.dailyData.length > 0;
},
totalDuration() {
return this.dailyData.reduce((sum, day) => sum + day.duration, 0);
},
totalSessions() {
return this.dailyData.reduce((sum, day) => sum + day.sessions, 0);
},
avgSessionLength() {
return this.totalSessions > 0 ? this.totalDuration / this.totalSessions : 0;
},
},
async mounted() {
this.categoryStore.load();
await this.bucketsStore.ensureLoaded();

if (this.hostOptions.length > 0) {
this.selectedHosts = this.hostOptions.map(opt => opt.value);
}
},
methods: {
async loadData() {
this.loading = true;
try {
const client = getClient();

if (this.selectedHosts.length === 0) {
alert('Please select at least one host');
this.loading = false;
return;
}

const timeperiods = this.getTimeperiods();
const breakTimeSeconds = this.breakTime * 60;
const categoriesFilter = this.selectedCategories.map(c => JSON.parse(c));

const categories = this.categoryStore.classes_for_query;
const categoriesStr = JSON.stringify(categories).replace(/\\\\/g, '\\');

// Build multi-device query with flood-based gap merging
let query = '';

for (const hostname of this.selectedHosts) {
const safeHost = hostname.replace(/[^a-zA-Z0-9_]/g, '');
query += `
events_${safeHost} = flood(query_bucket(find_bucket("aw-watcher-window_${safeHost}")), ${breakTimeSeconds});
Copy link
Contributor

Choose a reason for hiding this comment

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

safeHost used in find_bucket() won't match actual bucket IDs when hostnames contain special characters (e.g., "my-laptop" becomes "mylaptop"). Use original hostname variable instead.

Suggested change
events_${safeHost} = flood(query_bucket(find_bucket("aw-watcher-window_${safeHost}")), ${breakTimeSeconds});
events_${safeHost} = flood(query_bucket(find_bucket("aw-watcher-window_${hostname}")), ${breakTimeSeconds});

events_${safeHost} = categorize(events_${safeHost}, ${categoriesStr});
events_${safeHost} = filter_keyvals(events_${safeHost}, "$category", ${JSON.stringify(
categoriesFilter
)});
`;
}

// Combine events from all hosts
query += '\nevents = [];';
for (const hostname of this.selectedHosts) {
const safeHost = hostname.replace(/[^a-zA-Z0-9_]/g, '');
query += `\nevents = union_no_overlap(events, events_${safeHost});`;
}

query += `
duration = sum_durations(events);
RETURN = {"events": events, "duration": duration};
`;

const results = await client.query(timeperiods, [query]);

this.dailyData = timeperiods.map((tp, i) => {
const result = results[i];
const events = result.events || [];
const duration = result.duration || 0;

const startDate = tp.split('/')[0];

return {
date: moment(startDate).format('YYYY-MM-DD'),
duration,
sessions: events.length,
avgSession: events.length > 0 ? duration / events.length : 0,
events,
};
});

this.rawData = results;
} catch (error) {
console.error('Error loading work time data:', error);
alert('Error loading data. See console for details.');
} finally {
this.loading = false;
}
},

getTimeperiods(): string[] {
const offset = this.settingsStore.startOfDay;
const timeperiods: string[] = [];

let days: number;

if (this.dateRange === 'last7d') {
days = 7;
} else if (this.dateRange === 'last30d') {
days = 30;
} else if (this.dateRange === 'thisWeek') {
days = moment().isoWeekday(); // Mon=1 .. Sun=7
} else if (this.dateRange === 'thisMonth') {
days = moment().date(); // 1-based day of month
} else {
days = 7;
}

for (let i = days - 1; i >= 0; i--) {
const start = moment().subtract(i, 'days').startOf('day').add(offset);
const end = start.clone().add(1, 'day');
timeperiods.push(start.format() + '/' + end.format());
}

return timeperiods;
},

formatDuration(seconds: number): string {
if (!seconds) return '0:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}:${minutes.toString().padStart(2, '0')}`;
},

exportCSV() {
const headers = ['Date', 'Duration (hours)', 'Sessions', 'Avg Session (minutes)'];
const rows = this.dailyData.map(day => [
day.date,
(day.duration / 3600).toFixed(2),
day.sessions,
(day.avgSession / 60).toFixed(1),
]);

const csv = [
headers.join(','),
...rows.map(row => row.join(',')),
'',
`Total,${(this.totalDuration / 3600).toFixed(2)},${this.totalSessions},${(
this.avgSessionLength / 60
).toFixed(1)}`,
].join('\n');

this.downloadFile(csv, 'work_time_report.csv', 'text/csv');
},

exportJSON() {
const data = {
parameters: {
categories: this.selectedCategories,
breakTime: this.breakTime,
dateRange: this.dateRange,
},
summary: {
totalDuration: this.totalDuration,
totalSessions: this.totalSessions,
avgSessionLength: this.avgSessionLength,
},
daily: this.dailyData,
rawEvents: this.rawData,
};

const json = JSON.stringify(data, null, 2);
this.downloadFile(json, 'work_time_report.json', 'application/json');
},

downloadFile(content: string, filename: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
},
},
};
</script>

<style scoped>
.table {
font-size: 0.9rem;
}
</style>