Skip to content

Commit d4bfd6f

Browse files
feat(billing): add BillingView — category-grouped billable hours export (#938)
* feat(billing): add BillingView — category-grouped billable hours export Prototype for #899. Adds a /billing route with a panel that: - Queries all active (non-AFK) window events over a selected date range - Categorizes them using the user's existing AW category rules - Groups totals by $category (leaf path, e.g. "Work > Programming") - Lets users set a per-hour rate globally or per category row - Exports a CSV with: Category, Hours, Rate, Amount Design decision: grouping unit is the full category path (leaf), not project or tag, because $category is already first-class in AW and maps directly to invoice line items without extra watcher setup. Navigation: added "Billable Hours" entry next to Work Report in the Tools dropdown. * fix(billing): fix rate fallback, stale period, and CSV escaping - getEffectiveRate: treat explicit 0 as 'not billable' not 'unset'; previously, entering 0 for a category fell back to defaultRate, incorrectly billing non-billable time at the default rate - store queriedPeriod at load time; periodLabel and exportCSV now use the actual queried period, not the current moment at display time - escape double-quotes in CSV category fields per RFC 4180 * fix(billing): use JSON key for category identity, delay queriedPeriod on failure - Use JSON.stringify(categoryArray) as map key to prevent false collisions when a category segment name contains the ' > ' delimiter string - Move queriedPeriod assignment to after successful query, so that a failed refresh does not mislabel the still-visible previous rows - Separate row.key (identity/rate-lookup) from row.category (display/CSV)
1 parent 322159b commit d4bfd6f

3 files changed

Lines changed: 355 additions & 0 deletions

File tree

src/components/Header.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ div(:class="{'fixed-top-padding': fixedTopMenu}")
6060
b-dropdown-item(to="/work-report")
6161
icon(name="briefcase")
6262
| {{ $t('nav.workReport') }}
63+
b-dropdown-item(to="/billing")
64+
icon(name="dollar-sign")
65+
| Billable Hours
6366
b-dropdown-item(to="/analysis/activity" v-if="devmode")
6467
icon(name="robot")
6568
| {{ $t('nav.aiSummary') }}
@@ -102,6 +105,7 @@ div(:class="{'fixed-top-padding': fixedTopMenu}")
102105
// only import the icons you use to reduce bundle size
103106
import 'vue-awesome/icons/calendar-day';
104107
import 'vue-awesome/icons/briefcase';
108+
import 'vue-awesome/icons/dollar-sign';
105109
import 'vue-awesome/icons/calendar-week';
106110
import 'vue-awesome/icons/stream';
107111
import 'vue-awesome/icons/database';

src/route.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const Settings = () => import('./views/settings/Settings.vue');
1919
// pulling a second copy into a separate chunk.
2020
const Stopwatch = () => import('./views/Stopwatch.vue');
2121
const WorkReport = () => import('./views/WorkReport.vue');
22+
const BillingView = () => import('./views/BillingView.vue');
2223
const AISummaryView = () => import('./views/AISummaryView.vue');
2324
const Alerts = () => import('./views/Alerts.vue');
2425
const Search = () => import('./views/Search.vue');
@@ -83,6 +84,7 @@ const router = new VueRouter({
8384
},
8485
{ path: '/stopwatch', component: Stopwatch },
8586
{ path: '/work-report', component: WorkReport },
87+
{ path: '/billing', component: BillingView },
8688
{ path: '/analysis/activity', component: AISummaryView },
8789
{ path: '/search', component: Search },
8890
{ path: '/graph', component: Graph },

src/views/BillingView.vue

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
<template lang="pug">
2+
div
3+
h3.mb-3 Billable Hours Export
4+
5+
div.row.mb-4
6+
div.col-md-4
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-4
12+
b-form-group(label="Date Range" label-class="font-weight-bold")
13+
b-form-select(v-model="dateRange" :options="dateRangeOptions")
14+
15+
div.col-md-4
16+
b-form-group(label="Hourly Rate (optional)" label-class="font-weight-bold")
17+
b-input-group(prepend="$")
18+
b-form-input(
19+
v-model.number="defaultRate"
20+
type="number"
21+
min="0"
22+
step="0.01"
23+
placeholder="0.00"
24+
)
25+
small.text-muted Default rate applied to all categories. Override per row below.
26+
27+
div.mb-3
28+
b-button(@click="loadData" variant="primary" :disabled="loading")
29+
icon(name="sync")
30+
| Calculate Hours
31+
b-button.ml-2(@click="exportCSV" variant="outline-secondary" :disabled="!hasData")
32+
icon(name="download")
33+
| Export CSV
34+
35+
div(v-if="loading")
36+
b-spinner.mr-2
37+
| Loading...
38+
39+
div(v-if="errorMessage")
40+
b-alert(variant="danger" show) {{ errorMessage }}
41+
42+
div(v-if="hasData && !loading")
43+
div.row.mb-2
44+
div.col
45+
small.text-muted
46+
| Period: {{ periodLabel }} · Total: {{ formatDuration(totalDuration) }}
47+
span(v-if="defaultRate > 0") · Est. Total: {{ formatAmount(totalAmount) }}
48+
49+
table.table.table-sm.table-hover
50+
thead
51+
tr
52+
th Category
53+
th.text-right Hours
54+
th.text-right Rate ($/h)
55+
th.text-right Amount
56+
tbody
57+
tr(v-for="row in categoryRows" :key="row.key")
58+
td
59+
span.badge.mr-1(:style="{ background: '#6c757d', color: 'white' }") {{ row.depth > 0 ? '↳ ' : '' }}
60+
| {{ row.label }}
61+
td.text-right {{ formatHours(row.duration) }}
62+
td.text-right
63+
b-form-input(
64+
v-model.number="categoryRates[row.key]"
65+
type="number"
66+
min="0"
67+
step="0.01"
68+
size="sm"
69+
style="width: 80px; display: inline-block"
70+
:placeholder="defaultRate > 0 ? String(defaultRate) : '0.00'"
71+
)
72+
td.text-right {{ formatAmount(getAmount(row)) }}
73+
tfoot
74+
tr.font-weight-bold
75+
td Total
76+
td.text-right {{ formatHours(totalDuration) }}
77+
td.text-right
78+
td.text-right {{ formatAmount(totalAmount) }}
79+
</template>
80+
81+
<script lang="ts">
82+
import moment from 'moment';
83+
import { getClient } from '~/util/awclient';
84+
import { useCategoryStore } from '~/stores/categories';
85+
import { useSettingsStore } from '~/stores/settings';
86+
import { useBucketsStore } from '~/stores/buckets';
87+
import { get_day_start_with_offset, get_day_end_with_offset } from '~/util/time';
88+
import {
89+
getSupportedWorkReportHosts,
90+
getWorkReportHostOptions,
91+
getUnsupportedWorkReportHosts,
92+
} from '~/util/workReport';
93+
94+
import 'vue-awesome/icons/sync';
95+
import 'vue-awesome/icons/download';
96+
97+
interface CategoryRow {
98+
key: string; // JSON.stringify(parts) — unambiguous identity for rate lookup
99+
category: string; // parts.join(' > ') — display label in UI and CSV
100+
label: string;
101+
depth: number;
102+
duration: number;
103+
}
104+
105+
function buildBillingQuery(hosts: string[], categoriesStr: string): string {
106+
let query = '';
107+
for (let hi = 0; hi < hosts.length; hi++) {
108+
const h = hosts[hi];
109+
query += `
110+
events_${hi} = flood(query_bucket("aw-watcher-window_${h}"));
111+
not_afk_${hi} = flood(query_bucket("aw-watcher-afk_${h}"));
112+
not_afk_${hi} = filter_keyvals(not_afk_${hi}, "status", ["not-afk"]);
113+
events_${hi} = filter_period_intersect(events_${hi}, not_afk_${hi});
114+
events_${hi} = categorize(events_${hi}, ${categoriesStr});`;
115+
}
116+
query += '\nevents = [];';
117+
for (let hi = 0; hi < hosts.length; hi++) {
118+
query += `\nevents = union_no_overlap(events, events_${hi});`;
119+
}
120+
query += `
121+
duration = sum_durations(events);
122+
RETURN = {"events": events, "duration": duration};`;
123+
return query
124+
.split('\n')
125+
.map(line => line.replace(/\s+$/, ''))
126+
.join('\n');
127+
}
128+
129+
export default {
130+
name: 'BillingView',
131+
data() {
132+
return {
133+
categoryStore: useCategoryStore(),
134+
settingsStore: useSettingsStore(),
135+
bucketsStore: useBucketsStore(),
136+
137+
selectedHosts: [] as string[],
138+
dateRange: 'thisMonth',
139+
defaultRate: 0,
140+
categoryRates: {} as Record<string, number>,
141+
142+
loading: false,
143+
errorMessage: '',
144+
categoryRows: [] as CategoryRow[],
145+
totalDuration: 0,
146+
queriedPeriod: '' as string,
147+
};
148+
},
149+
computed: {
150+
hostOptions() {
151+
return getWorkReportHostOptions(this.bucketsStore.buckets || []);
152+
},
153+
dateRangeOptions() {
154+
return [
155+
{ value: 'thisMonth', text: 'This month' },
156+
{ value: 'last30d', text: 'Last 30 days' },
157+
{ value: 'thisWeek', text: 'This week' },
158+
{ value: 'last7d', text: 'Last 7 days' },
159+
];
160+
},
161+
hasData() {
162+
return this.categoryRows.length > 0;
163+
},
164+
totalAmount() {
165+
return this.categoryRows.reduce((sum, row) => sum + this.getAmount(row), 0);
166+
},
167+
periodLabel() {
168+
const tp = this.queriedPeriod || this.getTimeperiod();
169+
const [start, end] = tp.split('/');
170+
return `${moment(start).format('MMM D')} – ${moment(end).format('MMM D, YYYY')}`;
171+
},
172+
},
173+
async mounted() {
174+
this.categoryStore.load();
175+
await this.bucketsStore.ensureLoaded();
176+
if (this.hostOptions.length > 0) {
177+
this.selectedHosts = this.hostOptions.filter(opt => !opt.disabled).map(opt => opt.value);
178+
}
179+
},
180+
methods: {
181+
getTimeperiod(): string {
182+
const offset = this.settingsStore.startOfDay;
183+
let startDate: moment.Moment;
184+
const today = moment();
185+
186+
if (this.dateRange === 'thisMonth') {
187+
startDate = moment().startOf('month');
188+
} else if (this.dateRange === 'last30d') {
189+
startDate = today.clone().subtract(29, 'days');
190+
} else if (this.dateRange === 'thisWeek') {
191+
startDate = moment().startOf('isoWeek');
192+
} else {
193+
startDate = today.clone().subtract(6, 'days');
194+
}
195+
196+
const start = get_day_start_with_offset(startDate, offset);
197+
const end = get_day_end_with_offset(today, offset);
198+
return `${start}/${end}`;
199+
},
200+
201+
async loadData() {
202+
this.loading = true;
203+
this.errorMessage = '';
204+
try {
205+
const client = getClient();
206+
207+
if (this.selectedHosts.length === 0) {
208+
this.errorMessage = 'Please select at least one host.';
209+
return;
210+
}
211+
212+
const unsupported = getUnsupportedWorkReportHosts(
213+
this.selectedHosts,
214+
this.bucketsStore.buckets || []
215+
);
216+
const hostsToQuery = getSupportedWorkReportHosts(
217+
this.selectedHosts,
218+
this.bucketsStore.buckets || []
219+
);
220+
if (hostsToQuery.length === 0) {
221+
this.errorMessage = `No supported hosts (require aw-watcher-afk): ${unsupported.join(
222+
', '
223+
)}`;
224+
return;
225+
}
226+
227+
const categories = this.categoryStore.classes_for_query;
228+
const categoriesStr = JSON.stringify(categories).replace(/\\\\/g, '\\');
229+
const query = buildBillingQuery(hostsToQuery, categoriesStr);
230+
const tp = this.getTimeperiod();
231+
232+
const [result] = await client.query([tp], [query]);
233+
// Only update queriedPeriod after a successful query so that on
234+
// failure the existing rows continue to display with their correct period.
235+
this.queriedPeriod = tp;
236+
const events: any[] = result.events || [];
237+
this.totalDuration = result.duration || 0;
238+
239+
// Aggregate duration by category path.
240+
// Key: JSON.stringify(parts) — unambiguous even if a segment contains ' > '.
241+
const durationMap: Record<string, number> = {};
242+
for (const event of events) {
243+
const cat: string[] = event.data?.['$category'] || ['Uncategorized'];
244+
const key = JSON.stringify(cat);
245+
durationMap[key] = (durationMap[key] || 0) + event.duration;
246+
}
247+
248+
// Build rows sorted by display label.
249+
this.categoryRows = Object.entries(durationMap)
250+
.sort(([a], [b]) => a.localeCompare(b))
251+
.map(([key, duration]) => {
252+
const parts: string[] = JSON.parse(key);
253+
return {
254+
key,
255+
category: parts.join(' > '),
256+
label: parts[parts.length - 1],
257+
depth: parts.length - 1,
258+
duration,
259+
};
260+
});
261+
} catch (err: any) {
262+
this.errorMessage = `Error loading data: ${err?.message || err}`;
263+
console.error(err);
264+
} finally {
265+
this.loading = false;
266+
}
267+
},
268+
269+
getEffectiveRate(row: CategoryRow): number {
270+
const explicit = this.categoryRates[row.key];
271+
// Treat explicitly-entered 0 as "not billable" (don't fall back to defaultRate).
272+
// Only fall back when the field has never been touched (undefined/null/'').
273+
if (explicit !== undefined && explicit !== null && explicit !== '') return Number(explicit);
274+
return this.defaultRate || 0;
275+
},
276+
277+
getAmount(row: CategoryRow): number {
278+
return (row.duration / 3600) * this.getEffectiveRate(row);
279+
},
280+
281+
formatHours(seconds: number): string {
282+
return (seconds / 3600).toFixed(2);
283+
},
284+
285+
formatDuration(seconds: number): string {
286+
const h = Math.floor(seconds / 3600);
287+
const m = Math.floor((seconds % 3600) / 60);
288+
return `${h}h ${m}m`;
289+
},
290+
291+
formatAmount(amount: number): string {
292+
if (!amount) return '';
293+
return `$${amount.toFixed(2)}`;
294+
},
295+
296+
exportCSV() {
297+
const tp = this.queriedPeriod || this.getTimeperiod();
298+
const [start, end] = tp.split('/');
299+
const header = [
300+
`# Billable Hours Export`,
301+
`# Period: ${moment(start).format('YYYY-MM-DD')} to ${moment(end).format('YYYY-MM-DD')}`,
302+
`# Generated: ${moment().format('YYYY-MM-DD HH:mm')}`,
303+
'',
304+
].join('\n');
305+
306+
const cols = ['Category', 'Hours', 'Rate ($/h)', 'Amount ($)'];
307+
const rows = this.categoryRows.map(row => {
308+
const rate = this.getEffectiveRate(row);
309+
const amount = this.getAmount(row);
310+
return [
311+
'"' + row.category.replace(/"/g, '""') + '"',
312+
(row.duration / 3600).toFixed(2),
313+
rate > 0 ? rate.toFixed(2) : '',
314+
amount > 0 ? amount.toFixed(2) : '',
315+
].join(',');
316+
});
317+
const totalRate = '';
318+
const totalAmountStr = this.totalAmount > 0 ? this.totalAmount.toFixed(2) : '';
319+
const totalsRow = [
320+
'"TOTAL"',
321+
(this.totalDuration / 3600).toFixed(2),
322+
totalRate,
323+
totalAmountStr,
324+
].join(',');
325+
326+
const csv = header + [cols.join(','), ...rows, '', totalsRow].join('\n');
327+
this.downloadFile(csv, `billable-hours-${moment(start).format('YYYY-MM')}.csv`, 'text/csv');
328+
},
329+
330+
downloadFile(content: string, filename: string, mimeType: string) {
331+
const blob = new Blob([content], { type: mimeType });
332+
const url = URL.createObjectURL(blob);
333+
const a = document.createElement('a');
334+
a.href = url;
335+
a.download = filename;
336+
document.body.appendChild(a);
337+
a.click();
338+
document.body.removeChild(a);
339+
URL.revokeObjectURL(url);
340+
},
341+
},
342+
};
343+
</script>
344+
345+
<style scoped>
346+
.table {
347+
font-size: 0.9rem;
348+
}
349+
</style>

0 commit comments

Comments
 (0)