Skip to content

Commit 7765056

Browse files
committed
add tag grouping
1 parent 639f533 commit 7765056

File tree

7 files changed

+435
-295
lines changed

7 files changed

+435
-295
lines changed

app/Enums/TimeEntryAggregationType.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ enum TimeEntryAggregationType: string
2020
case Client = 'client';
2121
case Billable = 'billable';
2222
case Description = 'description';
23+
case Tag = 'tag';
2324

2425
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
2526
{

app/Service/TimeEntryAggregationService.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010
use App\Enums\Weekday;
1111
use App\Models\Client;
1212
use App\Models\Project;
13+
use App\Models\Tag;
1314
use App\Models\Task;
1415
use App\Models\TimeEntry;
1516
use App\Models\User;
1617
use Carbon\CarbonTimeZone;
1718
use Illuminate\Database\Eloquent\Builder;
1819
use Illuminate\Support\Carbon;
1920
use Illuminate\Support\Collection;
21+
use Illuminate\Support\Facades\DB;
2022
use Illuminate\Support\Facades\Log;
2123

2224
class TimeEntryAggregationService
@@ -45,9 +47,21 @@ class TimeEntryAggregationService
4547
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
4648
{
4749
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
50+
/** @var Builder<TimeEntry> $baseTotalsQuery */
51+
$baseTotalsQuery = $timeEntriesQuery->clone();
4852
$group1Select = null;
4953
$group2Select = null;
5054
$groupBy = null;
55+
// If any grouping is by tag, expand rows per tag and ensure a NULL row for entries without tags
56+
if (($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag)) {
57+
$timeEntriesQuery->crossJoin(DB::raw(
58+
"LATERAL (\n".
59+
" SELECT jsonb_array_elements_text(coalesce(tags, '[]'::jsonb)) AS tag\n".
60+
" UNION ALL\n".
61+
" SELECT ''::text AS tag WHERE coalesce(jsonb_array_length(tags), 0) = 0\n".
62+
') AS tag(tag)'
63+
));
64+
}
5165
if ($group1Type !== null) {
5266
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
5367
$groupBy = ['group_1'];
@@ -84,6 +98,26 @@ public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAg
8498
$group1Response = [];
8599
$group1ResponseSum = 0;
86100
$group1ResponseCost = 0;
101+
// If Tag is subgroup, prepare base totals per primary group without tag expansion
102+
$baseTotalsPerGroup1Map = [];
103+
if ($group2Type === TimeEntryAggregationType::Tag) {
104+
$baseTotalsPerGroup1Query = $baseTotalsQuery->clone();
105+
$baseTotalsPerGroup1 = $baseTotalsPerGroup1Query
106+
->selectRaw(
107+
$group1Select.' as group_1,'.
108+
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
109+
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
110+
)
111+
->groupBy('group_1')
112+
->get();
113+
foreach ($baseTotalsPerGroup1 as $row) {
114+
/** @var object{group_1: mixed, aggregate: int|null, cost: int|null} $row */
115+
$baseTotalsPerGroup1Map[(string) ($row->group_1 ?? '')] = [
116+
'aggregate' => (int) ($row->aggregate ?? 0),
117+
'cost' => (int) ($row->cost ?? 0),
118+
];
119+
}
120+
}
87121
foreach ($groupedAggregates as $group1 => $group1Aggregates) {
88122
/** @var string|int $group1 */
89123
$group2Response = [];
@@ -103,6 +137,14 @@ public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAg
103137
$group2ResponseSum += (int) $aggregate->get(0)->aggregate;
104138
$group2ResponseCost += (int) $aggregate->get(0)->cost;
105139
}
140+
// Override primary group totals when Tag is subgroup to avoid double counting
141+
if ($group2Type === TimeEntryAggregationType::Tag) {
142+
$keyForMap = (string) $group1;
143+
if (array_key_exists($keyForMap, $baseTotalsPerGroup1Map)) {
144+
$group2ResponseSum = $baseTotalsPerGroup1Map[$keyForMap]['aggregate'];
145+
$group2ResponseCost = $baseTotalsPerGroup1Map[$keyForMap]['cost'];
146+
}
147+
}
106148
} else {
107149
/** @var Collection<int, object{aggregate: int, cost: int}> $group1Aggregates */
108150
$group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate;
@@ -121,6 +163,23 @@ public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAg
121163
$group1ResponseCost += $group2ResponseCost;
122164
}
123165

166+
// If Tag is selected in any grouping, compute overall totals from base (non-tag-expanded) query to avoid double counting
167+
$hasTagGrouping = ($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag);
168+
if ($hasTagGrouping) {
169+
// Reset selects and ordering on the cloned base query
170+
$baseTotals = $baseTotalsQuery
171+
->selectRaw(
172+
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
173+
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
174+
)
175+
->first();
176+
if ($baseTotals !== null) {
177+
/** @var object{aggregate: int|null, cost: int|null} $baseTotals */
178+
$group1ResponseSum = (int) ($baseTotals->aggregate ?? 0);
179+
$group1ResponseCost = (int) ($baseTotals->cost ?? 0);
180+
}
181+
}
182+
124183
if ($fillGapsInTimeGroupsIsPossible) {
125184
$group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end);
126185
}
@@ -294,6 +353,17 @@ private function loadDescriptorsMap(array $keys, TimeEntryAggregationType $type)
294353
'color' => null,
295354
];
296355
}
356+
} elseif ($type === TimeEntryAggregationType::Tag) {
357+
$tags = Tag::query()
358+
->whereIn('id', $keys)
359+
->select('id', 'name')
360+
->get();
361+
foreach ($tags as $tag) {
362+
$descriptorMap[$tag->id] = [
363+
'description' => $tag->name,
364+
'color' => null,
365+
];
366+
}
297367
}
298368

299369
return $descriptorMap;
@@ -436,6 +506,8 @@ private function getGroupByQuery(TimeEntryAggregationType $group, string $timezo
436506
return 'billable';
437507
} elseif ($group === TimeEntryAggregationType::Description) {
438508
return 'description';
509+
} elseif ($group === TimeEntryAggregationType::Tag) {
510+
return 'tag';
439511
}
440512
}
441513

resources/js/Components/Common/Reporting/ReportingChart.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ const option = computed(() => ({
113113
},
114114
axisLabel: {
115115
fontSize: 12,
116-
fontWeight: 600,
116+
fontWeight: 400,
117117
color: labelColor.value,
118118
margin: 16,
119119
fontFamily: 'Inter, sans-serif',

resources/js/Components/Common/Reporting/ReportingRow.vue

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
3030
<template>
3131
<div
3232
class="contents text-text-primary [&>*]:transition [&>*]:border-card-background-separator [&>*]:border-b [&>*]:h-[50px]">
33-
<div
34-
:class="
35-
twMerge('pl-6 font-medium flex items-center space-x-3', props.indent ? 'pl-16' : '')
36-
">
33+
<div :class="twMerge('pl-6 flex items-center space-x-3', props.indent ? 'pl-16' : '')">
3734
<GroupedItemsCountButton
3835
v-if="entry.grouped_data && entry.grouped_data?.length > 0"
3936
:expanded="expanded"

0 commit comments

Comments
 (0)