Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
59.40% covered (warning)
59.40%
199 / 335
41.18% covered (danger)
41.18%
7 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
RolePlayReportService
59.40% covered (warning)
59.40%
199 / 335
41.18% covered (danger)
41.18%
7 / 17
274.39
0.00% covered (danger)
0.00%
0 / 1
 availableCharts
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getChartData
46.15% covered (danger)
46.15%
6 / 13
0.00% covered (danger)
0.00%
0 / 1
2.62
 getSummaryData
89.55% covered (warning)
89.55%
60 / 67
0.00% covered (danger)
0.00%
0 / 1
13.19
 getSpotlight
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
4
 getExportData
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
4
 chartSessionsCompleted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 chartPracticeTime
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 chartActiveUsers
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 chartAverageScore
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 chartScoreByCallType
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
12
 chartScoreBySection
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 chartScoreDistribution
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
30
 chartCompletionRate
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 buildConversationMatch
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
3.27
 monthlyAggregate
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 fillMonthlyGaps
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 emptyChartResponse
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3namespace App\Http\Services\Reports;
4
5use App\Http\Models\RolePlayConversations;
6use App\Http\Models\RolePlayProjects;
7use App\Http\Models\UserInfo;
8use Carbon\Carbon;
9use MongoDB\BSON\UTCDateTime;
10
11/**
12 * Service for generating roleplay (Sales Training) analytics reports.
13 *
14 * Provides aggregation pipelines against the role_play_conversations collection
15 * to produce chart data, summary statistics, spotlight KPIs, and CSV exports
16 * for the admin and CMC reporting panels.
17 */
18class RolePlayReportService
19{
20    /**
21     * Available chart types and their metadata.
22     *
23     * @var array<string, array{label: string, tooltip: string}>
24     */
25    private array $chartMeta = [
26        'sessions_completed' => [
27            'label' => 'Sessions Completed',
28            'tooltip' => 'Total number of completed roleplay sessions over the selected period.',
29        ],
30        'practice_time' => [
31            'label' => 'Practice Time (hrs)',
32            'tooltip' => 'Total practice time in hours across all completed sessions.',
33        ],
34        'active_users' => [
35            'label' => 'Active Practitioners',
36            'tooltip' => 'Number of unique users who completed at least one session.',
37        ],
38        'average_score' => [
39            'label' => 'Average Score',
40            'tooltip' => 'Average session score (0-100) across all completed sessions.',
41        ],
42        'score_by_call_type' => [
43            'label' => 'Score by Call Type',
44            'tooltip' => 'Average score breakdown by Cold Call vs Discovery Call.',
45        ],
46        'score_by_section' => [
47            'label' => 'Score by Section',
48            'tooltip' => 'Average scores across scorecard sections (e.g. Introduction, Value Proposition).',
49        ],
50        'score_distribution' => [
51            'label' => 'Score Distribution',
52            'tooltip' => 'Distribution of session scores across score ranges.',
53        ],
54        'completion_rate' => [
55            'label' => 'Completion Rate (%)',
56            'tooltip' => 'Percentage of sessions completed successfully vs total sessions started.',
57        ],
58    ];
59
60    /**
61     * Return available chart definitions.
62     *
63     * @param  bool  $includeCmcOnly  Whether to include CMC-only charts (e.g. completion_rate)
64     * @return array<int, array{value: string, label: string}>
65     */
66    public function availableCharts(bool $includeCmcOnly = false): array
67    {
68        $charts = [];
69        foreach ($this->chartMeta as $value => $meta) {
70            if ($value === 'completion_rate' && ! $includeCmcOnly) {
71                continue;
72            }
73            $charts[] = ['value' => $value, 'label' => $meta['label']];
74        }
75
76        return $charts;
77    }
78
79    /**
80     * Generate time-series chart data for a given chart type.
81     *
82     * @param  string  $type  The chart type identifier
83     * @param  array  $userIds  Scoped user IDs
84     * @param  Carbon  $startDate  Start of the date range
85     * @param  Carbon  $endDate  End of the date range
86     * @param  string|null  $callType  Optional call type filter (cold-call, discovery-call)
87     * @return array  Chart response data
88     */
89    public function getChartData(string $type, array $userIds, Carbon $startDate, Carbon $endDate, ?string $callType = null): array
90    {
91        if (empty($userIds)) {
92            return $this->emptyChartResponse($type, $startDate, $endDate);
93        }
94
95        return match ($type) {
96            'sessions_completed' => $this->chartSessionsCompleted($userIds, $startDate, $endDate, $callType),
97            'practice_time' => $this->chartPracticeTime($userIds, $startDate, $endDate, $callType),
98            'active_users' => $this->chartActiveUsers($userIds, $startDate, $endDate, $callType),
99            'average_score' => $this->chartAverageScore($userIds, $startDate, $endDate, $callType),
100            'score_by_call_type' => $this->chartScoreByCallType($userIds, $startDate, $endDate),
101            'score_by_section' => $this->chartScoreBySection($userIds, $startDate, $endDate, $callType),
102            'score_distribution' => $this->chartScoreDistribution($userIds, $startDate, $endDate, $callType),
103            'completion_rate' => $this->chartCompletionRate($userIds, $startDate, $endDate, $callType),
104            default => ['chart' => []],
105        };
106    }
107
108    /**
109     * Generate summary data (total, average, top 5 users) for a given chart type.
110     *
111     * @param  string  $type  The chart type identifier
112     * @param  array  $userIds  Scoped user IDs
113     * @param  Carbon  $startDate  Start of the date range
114     * @param  Carbon  $endDate  End of the date range
115     * @param  string|null  $callType  Optional call type filter
116     * @return array  Summary data with label, title, tooltip, total, average, top 5
117     */
118    public function getSummaryData(string $type, array $userIds, Carbon $startDate, Carbon $endDate, ?string $callType = null): array
119    {
120        $meta = $this->chartMeta[$type] ?? ['label' => ucfirst($type), 'tooltip' => ''];
121        $baseMatch = $this->buildConversationMatch($userIds, $startDate, $endDate, 'done', $callType);
122
123        if (empty($userIds)) {
124            return [
125                'label' => $meta['label'],
126                'title' => $meta['label'],
127                'tooltip' => $meta['tooltip'],
128                'total' => 0,
129                'average' => 0,
130                'top' => [],
131            ];
132        }
133
134        $topMetric = match ($type) {
135            'sessions_completed', 'active_users' => 'count',
136            'practice_time' => 'duration',
137            'average_score' => 'score',
138            default => 'count',
139        };
140
141        // Get per-user aggregates
142        $groupOp = match ($topMetric) {
143            'count' => ['$sum' => 1],
144            'duration' => ['$sum' => '$duration'],
145            'score' => ['$avg' => '$score'],
146        };
147
148        $pipeline = [
149            ['$match' => $baseMatch],
150            ['$group' => ['_id' => '$user_id', 'value' => $groupOp]],
151        ];
152
153        $results = collect(RolePlayConversations::raw(fn ($c) => $c->aggregate($pipeline)));
154        $userValues = [];
155        $total = 0;
156        $userCount = 0;
157        foreach ($results as $row) {
158            $val = $row['value'] ?? 0;
159            if ($topMetric === 'duration') {
160                $val = round($val / 3600, 2); // seconds to hours
161            }
162            $userValues[$row['_id']] = $val;
163            $total += ($topMetric === 'score') ? 0 : $val;
164            $userCount++;
165        }
166
167        if ($topMetric === 'score') {
168            // For average score, total is the mean across users
169            $total = $userCount > 0 ? round(array_sum($userValues) / $userCount, 1) : 0;
170        }
171
172        // Resolve user names for top 5
173        arsort($userValues);
174        $topUserIds = array_slice(array_keys($userValues), 0, 5);
175
176        $userInfoPipeline = [
177            ['$match' => ['user_id' => ['$in' => $topUserIds]]],
178            ['$project' => ['_id' => 0, 'user_id' => 1, 'full_name' => 1, 'avatar' => 1]],
179        ];
180        $usersInfo = collect(UserInfo::raw(fn ($c) => $c->aggregate($userInfoPipeline)));
181        $userMap = [];
182        foreach ($usersInfo as $u) {
183            $userMap[$u['user_id']] = ['name' => $u['full_name'] ?? 'Unknown', 'avatar' => $u['avatar'] ?? null];
184        }
185
186        $top = [];
187        foreach ($topUserIds as $uid) {
188            $info = $userMap[$uid] ?? ['name' => 'Deleted User', 'avatar' => null];
189            $top[] = [
190                'name' => $info['name'],
191                'avatar' => $info['avatar'],
192                'value' => round($userValues[$uid], 2),
193            ];
194        }
195
196        $average = $userCount > 0 ? round(($topMetric === 'score' ? $total : $total / $userCount), 2) : 0;
197
198        $round = in_array($type, ['practice_time', 'average_score']) ? 2 : 0;
199
200        return [
201            'label' => $meta['label'],
202            'title' => $meta['label'],
203            'tooltip' => $meta['tooltip'],
204            'total' => $topMetric === 'score' ? $total : number_format(round($total, $round), $round),
205            'average' => number_format($average, 2),
206            'top' => $top,
207        ];
208    }
209
210    /**
211     * Generate spotlight KPI cards for the sales training dashboard.
212     *
213     * @param  array  $userIds  Scoped user IDs
214     * @param  Carbon  $startDate  Start of the date range
215     * @param  Carbon  $endDate  End of the date range
216     * @param  string|null  $callType  Optional call type filter
217     * @return array  Array of KPI card data
218     */
219    public function getSpotlight(array $userIds, Carbon $startDate, Carbon $endDate, ?string $callType = null): array
220    {
221        if (empty($userIds)) {
222            return [
223                ['title' => 'Total Sessions', 'subtitle' => 'Completed Sessions', 'tooltip' => 'Total completed roleplay sessions.', 'amount' => 0, 'average' => ['value' => 0, 'label' => 'Average per User']],
224                ['title' => 'Practice Hours', 'subtitle' => 'Total Practice Time', 'tooltip' => 'Total time spent in roleplay sessions.', 'amount' => 0, 'average' => ['value' => 0, 'label' => 'Average per User']],
225                ['title' => 'Average Score', 'subtitle' => 'Overall Average', 'tooltip' => 'Average score across all completed sessions.', 'amount' => 0, 'average' => ['value' => 0, 'label' => 'Across all users']],
226                ['title' => 'Active Practitioners', 'subtitle' => 'Unique Users', 'tooltip' => 'Number of unique users with completed sessions.', 'amount' => 0, 'average' => ['value' => 0, 'label' => 'Sessions per User']],
227            ];
228        }
229
230        $baseMatch = $this->buildConversationMatch($userIds, $startDate, $endDate, 'done', $callType);
231
232        $pipeline = [
233            ['$match' => $baseMatch],
234            ['$group' => [
235                '_id' => null,
236                'sessions' => ['$sum' => 1],
237                'totalDuration' => ['$sum' => '$duration'],
238                'avgScore' => ['$avg' => '$score'],
239                'uniqueUsers' => ['$addToSet' => '$user_id'],
240            ]],
241            ['$project' => [
242                '_id' => 0,
243                'sessions' => 1,
244                'totalDuration' => 1,
245                'avgScore' => 1,
246                'activeUsers' => ['$size' => '$uniqueUsers'],
247            ]],
248        ];
249
250        $result = collect(RolePlayConversations::raw(fn ($c) => $c->aggregate($pipeline)))->first();
251
252        $sessions = $result->sessions ?? 0;
253        $hours = round(($result->totalDuration ?? 0) / 3600, 2);
254        $avgScore = round($result->avgScore ?? 0, 1);
255        $activeUsers = $result->activeUsers ?? 0;
256        $sessionsPerUser = $activeUsers > 0 ? round($sessions / $activeUsers, 1) : 0;
257        $hoursPerUser = $activeUsers > 0 ? round($hours / $activeUsers, 2) : 0;
258
259        return [
260            ['title' => 'Total Sessions', 'subtitle' => 'Completed Sessions', 'tooltip' => 'Total completed roleplay sessions over the selected period.', 'amount' => number_format($sessions), 'average' => ['value' => number_format($sessionsPerUser, 1), 'label' => 'Average per User']],
261            ['title' => 'Practice Hours', 'subtitle' => 'Total Practice Time', 'tooltip' => 'Total time spent in roleplay sessions (hours).', 'amount' => number_format($hours, 2), 'average' => ['value' => number_format($hoursPerUser, 2), 'label' => 'Average per User']],
262            ['title' => 'Average Score', 'subtitle' => 'Overall Average', 'tooltip' => 'Average score (0-100) across all completed sessions.', 'amount' => $avgScore, 'average' => ['value' => $avgScore, 'label' => 'Across all users']],
263            ['title' => 'Active Practitioners', 'subtitle' => 'Unique Users', 'tooltip' => 'Number of unique users who completed at least one session.', 'amount' => number_format($activeUsers), 'average' => ['value' => number_format($sessionsPerUser, 1), 'label' => 'Sessions per User']],
264        ];
265    }
266
267    /**
268     * Generate CSV export data for roleplay analytics.
269     *
270     * @param  array  $userIds  Scoped user IDs
271     * @param  Carbon  $startDate  Start of the date range
272     * @param  Carbon  $endDate  End of the date range
273     * @param  string|null  $callType  Optional call type filter
274     * @return array  Array of rows with headers
275     */
276    public function getExportData(array $userIds, Carbon $startDate, Carbon $endDate, ?string $callType = null): array
277    {
278        if (empty($userIds)) {
279            return [
280                'headers' => ['User Name', 'Email', 'Sessions Completed', 'Practice Time (hrs)', 'Average Score'],
281                'rows' => [],
282            ];
283        }
284
285        $baseMatch = $this->buildConversationMatch($userIds, $startDate, $endDate, 'done', $callType);
286
287        $pipeline = [
288            ['$match' => $baseMatch],
289            ['$group' => [
290                '_id' => '$user_id',
291                'sessions' => ['$sum' => 1],
292                'totalDuration' => ['$sum' => '$duration'],
293                'avgScore' => ['$avg' => '$score'],
294            ]],
295        ];
296
297        $results = collect(RolePlayConversations::raw(fn ($c) => $c->aggregate($pipeline)));
298        $userAggregates = [];
299        foreach ($results as $row) {
300            $userAggregates[$row['_id']] = [
301                'sessions' => $row['sessions'],
302                'hours' => round($row['totalDuration'] / 3600, 2),
303                'avgScore' => round($row['avgScore'], 1),
304            ];
305        }
306
307        // Get user info
308        $userInfoPipeline = [
309            ['$match' => ['user_id' => ['$in' => $userIds]]],
310            ['$project' => ['_id' => 0, 'user_id' => 1, 'full_name' => 1, 'email' => 1]],
311        ];
312        $usersInfo = collect(UserInfo::raw(fn ($c) => $c->aggregate($userInfoPipeline)));
313
314        $rows = [];
315        foreach ($usersInfo as $u) {
316            $userId = $u['user_id'];
317            $agg = $userAggregates[$userId] ?? ['sessions' => 0, 'hours' => 0, 'avgScore' => 0];
318            $rows[] = [
319                $u['full_name'] ?? 'Unknown',
320                $u['email'] ?? '',
321                $agg['sessions'],
322                $agg['hours'],
323                $agg['avgScore'],
324            ];
325        }
326
327        return [
328            'headers' => ['User Name', 'Email', 'Sessions Completed', 'Practice Time (hrs)', 'Average Score'],
329            'rows' => $rows,
330        ];
331    }
332
333    // â”€â”€â”€ Private Chart Methods â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
334
335    private function chartSessionsCompleted(array $userIds, Carbon $startDate, Carbon $endDate, ?string $callType): array
336    {
337        return ['chart' => $this->monthlyAggregate($userIds, $startDate, $endDate, ['$sum' => 1], 0, $callType)];
338    }
339
340    private function chartPracticeTime(array $userIds, Carbon $startDate, Carbon $endDate, ?string $callType): array
341    {
342        $raw = $this->monthlyAggregate($userIds, $startDate, $endDate, ['$sum' => '$duration'], 2, $callType);
343        // Convert seconds to hours
344        foreach ($raw as &$point) {
345            $point['value'] = round($point['value'] / 3600, 2);
346        }
347
348        return ['chart' => $raw];
349    }
350
351    private function chartActiveUsers(array $userIds, Carbon $startDate, Carbon $endDate, ?string $callType): array
352    {
353        $baseMatch = $this->buildConversationMatch($userIds, $startDate, $endDate, 'done', $callType);
354
355        $pipeline = [
356            ['$match' => $baseMatch],
357            ['$group' => [
358                '_id' => ['$dateToString' => ['format' => '%Y-%m', 'date' => '$created_at']],
359                'users' => ['$addToSet' => '$user_id'],
360            ]],
361            ['$project' => ['_id' => 0, 'month_year' => '$_id', 'total' => ['$size' => '$users']]],
362            ['$sort' => ['month_year' => 1]],
363        ];
364
365        $results = collect(RolePlayConversations::raw(fn ($c) => $c->aggregate($pipeline)));
366        $dbData = [];
367        foreach ($results as $row) {
368            $dbData[$row['month_year']] = $row['total'];
369        }
370
371        return ['chart' => $this->fillMonthlyGaps($dbData, $startDate, $endDate)];
372    }
373
374    private function chartAverageScore(array $userIds, Carbon $startDate, Carbon $endDate, ?string $callType): array
375    {
376        return ['chart' => $this->monthlyAggregate($userIds, $startDate, $endDate, ['$avg' => '$score'], 1, $callType)];
377    }
378
379    private function chartScoreByCallType(array $userIds, Carbon $startDate, Carbon $endDate): array
380    {
381        $baseMatch = $this->buildConversationMatch($userIds, $startDate, $endDate, 'done');
382
383        $pipeline = [
384            ['$match' => $baseMatch],
385            ['$lookup' => [
386                'from' => 'role_play_projects',
387                'localField' => 'project_id',
388                'foreignField' => '_id',
389                'as' => 'project',
390            ]],
391            ['$unwind' => ['path' => '$project', 'preserveNullAndEmptyArrays' => false]],
392            ['$group' => [
393                '_id' => [
394                    'month' => ['$dateToString' => ['format' => '%Y-%m', 'date' => '$created_at']],
395                    'type' => '$project.type',
396                ],
397                'avgScore' => ['$avg' => '$score'],
398            ]],
399            ['$sort' => ['_id.month' => 1]],
400        ];
401
402        $results = collect(RolePlayConversations::raw(fn ($c) => $c->aggregate($pipeline)));
403
404        $dataByMonth = [];
405        foreach ($results as $row) {
406            $month = $row['_id']['month'];
407            $type = str_replace('-', '_', $row['_id']['type'] ?? 'unknown');
408            $dataByMonth[$month][$type] = round($row['avgScore'], 1);
409        }
410
411        $chart = [];
412        $period = new \DatePeriod($startDate->copy()->startOfMonth(), new \DateInterval('P1M'), $endDate->copy()->endOfMonth());
413        foreach ($period as $date) {
414            $monthKey = $date->format('Y-m');
415            $point = ['period' => $date->format('M Y')];
416            $point['cold_call'] = $dataByMonth[$monthKey]['cold_call'] ?? 0;
417            $point['discovery_call'] = $dataByMonth[$monthKey]['discovery_call'] ?? 0;
418            $chart[] = $point;
419        }
420
421        return ['chart' => $chart, 'series' => ['cold_call', 'discovery_call']];
422    }
423
424    private function chartScoreBySection(array $userIds, Carbon $startDate, Carbon $endDate, ?string $callType): array
425    {
426        $baseMatch = $this->buildConversationMatch($userIds, $startDate, $endDate, 'done', $callType);
427        $baseMatch['feedback.sections'] = ['$exists' => true, '$ne' => null];
428
429        $pipeline = [
430            ['$match' => $baseMatch],
431            ['$unwind' => '$feedback.sections'],
432            ['$unwind' => '$feedback.sections.criteria'],
433            ['$group' => [
434                '_id' => '$feedback.sections.section',
435                'avgScore' => ['$avg' => '$feedback.sections.criteria.score'],
436                'count' => ['$sum' => 1],
437            ]],
438            ['$sort' => ['_id' => 1]],
439        ];
440
441        $results = collect(RolePlayConversations::raw(fn ($c) => $c->aggregate($pipeline)));
442
443        $sections = [];
444        foreach ($results as $row) {
445            if (! empty($row['_id'])) {
446                $sections[] = ['name' => $row['_id'], 'score' => round($row['avgScore'], 1)];
447            }
448        }
449
450        return ['sections' => $sections];
451    }
452
453    private function chartScoreDistribution(array $userIds, Carbon $startDate, Carbon $endDate, ?string $callType): array
454    {
455        $baseMatch = $this->buildConversationMatch($userIds, $startDate, $endDate, 'done', $callType);
456        $baseMatch['score'] = ['$exists' => true, '$ne' => null];
457
458        $pipeline = [
459            ['$match' => $baseMatch],
460            ['$bucket' => [
461                'groupBy' => '$score',
462                'boundaries' => [0, 20, 40, 60, 80, 101],
463                'default' => 'other',
464                'output' => ['count' => ['$sum' => 1]],
465            ]],
466        ];
467
468        $results = collect(RolePlayConversations::raw(fn ($c) => $c->aggregate($pipeline)));
469
470        $rangeLabels = [0 => '0-19', 20 => '20-39', 40 => '40-59', 60 => '60-79', 80 => '80-100'];
471        $buckets = [];
472        foreach ($rangeLabels as $boundary => $label) {
473            $buckets[$label] = 0;
474        }
475
476        foreach ($results as $row) {
477            $id = $row['_id'];
478            if (isset($rangeLabels[$id])) {
479                $buckets[$rangeLabels[$id]] = $row['count'];
480            }
481        }
482
483        $result = [];
484        foreach ($buckets as $range => $count) {
485            $result[] = ['range' => $range, 'count' => $count];
486        }
487
488        return ['buckets' => $result];
489    }
490
491    private function chartCompletionRate(array $userIds, Carbon $startDate, Carbon $endDate, ?string $callType): array
492    {
493        $allMatch = $this->buildConversationMatch($userIds, $startDate, $endDate, null, $callType);
494
495        $pipeline = [
496            ['$match' => $allMatch],
497            ['$group' => [
498                '_id' => ['$dateToString' => ['format' => '%Y-%m', 'date' => '$created_at']],
499                'total' => ['$sum' => 1],
500                'done' => ['$sum' => ['$cond' => [['$eq' => ['$status', 'done']], 1, 0]]],
501            ]],
502            ['$project' => [
503                '_id' => 0,
504                'month_year' => '$_id',
505                'total' => ['$cond' => [['$gt' => ['$total', 0]], ['$round' => [['$multiply' => [['$divide' => ['$done', '$total']], 100]], 1]], 0]],
506            ]],
507            ['$sort' => ['month_year' => 1]],
508        ];
509
510        $results = collect(RolePlayConversations::raw(fn ($c) => $c->aggregate($pipeline)));
511        $dbData = [];
512        foreach ($results as $row) {
513            $dbData[$row['month_year']] = $row['total'];
514        }
515
516        return ['chart' => $this->fillMonthlyGaps($dbData, $startDate, $endDate)];
517    }
518
519    // â”€â”€â”€ Helpers â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
520
521    /**
522     * Build a MongoDB $match query for role_play_conversations.
523     */
524    private function buildConversationMatch(array $userIds, Carbon $startDate, Carbon $endDate, ?string $status = 'done', ?string $callType = null): array
525    {
526        $match = [
527            'user_id' => ['$in' => $userIds],
528            'created_at' => [
529                '$gte' => new UTCDateTime($startDate),
530                '$lte' => new UTCDateTime($endDate),
531            ],
532        ];
533
534        if ($status) {
535            $match['status'] = $status;
536        }
537
538        if ($callType) {
539            // Call type is on the project, so we need to resolve project IDs first
540            $projectIds = RolePlayProjects::where('type', $callType)
541                ->whereIn('user_id', $userIds)
542                ->pluck('_id')
543                ->all();
544            $match['project_id'] = ['$in' => array_map('strval', $projectIds)];
545        }
546
547        return $match;
548    }
549
550    /**
551     * Run a standard monthly aggregation on role_play_conversations.
552     */
553    private function monthlyAggregate(array $userIds, Carbon $startDate, Carbon $endDate, array $groupOp, int $round = 0, ?string $callType = null): array
554    {
555        $baseMatch = $this->buildConversationMatch($userIds, $startDate, $endDate, 'done', $callType);
556
557        $pipeline = [
558            ['$match' => $baseMatch],
559            ['$group' => [
560                '_id' => ['$dateToString' => ['format' => '%Y-%m', 'date' => '$created_at']],
561                'totalValue' => $groupOp,
562            ]],
563            ['$sort' => ['_id' => 1]],
564            ['$project' => ['_id' => 0, 'month_year' => '$_id', 'total' => ['$round' => ['$totalValue', $round]]]],
565        ];
566
567        $results = collect(RolePlayConversations::raw(fn ($c) => $c->aggregate($pipeline)));
568        $dbData = [];
569        foreach ($results as $row) {
570            $dbData[$row['month_year']] = $row['total'];
571        }
572
573        return $this->fillMonthlyGaps($dbData, $startDate, $endDate);
574    }
575
576    /**
577     * Fill in gaps in monthly data so every month has a data point.
578     */
579    private function fillMonthlyGaps(array $dbData, Carbon $startDate, Carbon $endDate): array
580    {
581        $chart = [];
582        $period = new \DatePeriod($startDate->copy()->startOfMonth(), new \DateInterval('P1M'), $endDate->copy()->endOfMonth());
583        foreach ($period as $date) {
584            $monthKey = $date->format('Y-m');
585            $chart[] = ['period' => $date->format('M Y'), 'value' => $dbData[$monthKey] ?? 0];
586        }
587
588        return $chart;
589    }
590
591    /**
592     * Return an empty chart response with the correct monthly period structure.
593     */
594    private function emptyChartResponse(string $type, Carbon $startDate, Carbon $endDate): array
595    {
596        if (in_array($type, ['score_by_section'])) {
597            return ['sections' => []];
598        }
599        if (in_array($type, ['score_distribution'])) {
600            return ['buckets' => [
601                ['range' => '0-19', 'count' => 0],
602                ['range' => '20-39', 'count' => 0],
603                ['range' => '40-59', 'count' => 0],
604                ['range' => '60-79', 'count' => 0],
605                ['range' => '80-100', 'count' => 0],
606            ]];
607        }
608        if ($type === 'score_by_call_type') {
609            return ['chart' => $this->fillMonthlyGaps([], $startDate, $endDate), 'series' => ['cold_call', 'discovery_call']];
610        }
611
612        return ['chart' => $this->fillMonthlyGaps([], $startDate, $endDate)];
613    }
614}