1010use App \Enums \Weekday ;
1111use App \Models \Client ;
1212use App \Models \Project ;
13+ use App \Models \Tag ;
1314use App \Models \Task ;
1415use App \Models \TimeEntry ;
1516use App \Models \User ;
1617use Carbon \CarbonTimeZone ;
1718use Illuminate \Database \Eloquent \Builder ;
1819use Illuminate \Support \Carbon ;
1920use Illuminate \Support \Collection ;
21+ use Illuminate \Support \Facades \DB ;
2022use Illuminate \Support \Facades \Log ;
2123
2224class 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
0 commit comments