Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.67% covered (success)
98.67%
74 / 75
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
RolePlayDashboardController
98.67% covered (success)
98.67%
74 / 75
83.33% covered (warning)
83.33%
5 / 6
16
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 progression
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 progressionByCallType
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
3.00
 allowedTopLevelSectionNames
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 filterSnapshotSections
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 totals
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace App\Http\Controllers\v2\RolePlay;
4
5use App\Http\Controllers\Controller;
6use App\Http\Repositories\interfaces\IRolePlayProjectsRepository;
7use App\Http\Services\RolePlay\RolePlaySectionAveragesService;
8use App\Http\Services\ScorecardResolverService;
9use App\Services\RolePlay\DashboardMetricService;
10use App\Traits\SubscriptionTrait;
11use Illuminate\Http\JsonResponse;
12use Illuminate\Http\Request;
13
14/**
15 * RolePlay Dashboard Controller
16 *
17 * Backs the roleplay home dashboard: aggregated progression (overall +
18 * per-call-type, optionally period-filtered), weekly/monthly totals with
19 * week-over-week evolution, persona count, and the "Recent Sessions" card.
20 *
21 * Call-type scoping is resolved from UserRolePlayProgression.entries
22 * (the authoritative session/project id record frozen at completion
23 * time) so totals stay consistent with the progression endpoint even
24 * after personas are renamed or soft-deleted. Data-layer operations are
25 * delegated to {@see DashboardMetricService} and the projects repository;
26 * radar-chart benchmarks come from {@see RolePlaySectionAveragesService}.
27 */
28class RolePlayDashboardController extends Controller
29{
30    use SubscriptionTrait;
31
32    public function __construct(
33        private readonly DashboardMetricService $metricService,
34        private readonly RolePlaySectionAveragesService $sectionAverages,
35        private readonly ScorecardResolverService $scorecardResolver,
36        private readonly IRolePlayProjectsRepository $projectsRepository,
37    ) {}
38
39    public function progression(Request $request): JsonResponse
40    {
41        $user = $request->user();
42
43        $progressionStats = $this->metricService->getProgressionStats($user);
44
45        return response()->json([
46            'status' => 'success',
47            'data' => $progressionStats,
48        ]);
49    }
50
51    /**
52     * Get progression stats for a specific call type.
53     *
54     * Returns overall score, per-section scores, trend (last 20 sessions),
55     * session count, and last session timestamp from UserRolePlayProgression.
56     *
57     * @param  string  $callType  The call type (e.g., 'cold-call', 'discovery-call')
58     * @return JsonResponse
59     *
60     * The response also includes pre-computed section-balance averages for
61     * the viewer's company (if any) and the global user base, used by the
62     * dashboard radar chart. Each average carries its own `computed_at`
63     * timestamp (refreshed every 6h by the scheduler). Datasets with no
64     * data are returned as `null` so the frontend hides them.
65     *
66     * @queryParam period string Optional period filter. One of 'thisWeek', 'thisMonth', or 'allTimes'.
67     *                          When provided, sections/overall/trend/session_count/last_session
68     *                          are recomputed over the filtered window as straight means. When
69     *                          omitted, the response uses the pre-computed EMA averages.
70     *
71     * @response 200 {
72     *   "result": {
73     *     "overall": 72.5,
74     *     "sections": [{"name": "Introduction", "score": 80.0}],
75     *     "trend": [{"date": "2026-03-27T10:00:00+00:00", "score": 70}],
76     *     "session_count": 5,
77     *     "last_session": "2026-03-27T10:00:00+00:00",
78     *     "company_average": {
79     *       "sections": [{"name": "Introduction", "score": 71.2}],
80     *       "user_count": 14,
81     *       "computed_at": "2026-04-10T06:00:00+00:00"
82     *     },
83     *     "global_average": {
84     *       "sections": [{"name": "Introduction", "score": 64.7}],
85     *       "user_count": 812,
86     *       "computed_at": "2026-04-10T06:00:00+00:00"
87     *     }
88     *   }
89     * }
90     */
91    public function progressionByCallType(Request $request, string $callType): JsonResponse
92    {
93        $user = $request->user();
94
95        // Pass through 'allTimes' explicitly (not as null) so the service
96        // switches to the recomputed plain-mean path instead of the legacy
97        // EMA averages, keeping 'All Time' consistent with 'This Month' /
98        // 'This Week' on the dashboard.
99        $period = $request->query('period');
100        if ($period !== null && ! in_array($period, ['thisWeek', 'thisMonth', 'allTimes'], true)) {
101            $period = null;
102        }
103
104        $stats = $this->metricService->getProgressionStatsByCallType(
105            $user->id,
106            $callType,
107            $period
108        );
109
110        $averages = $this->sectionAverages->getForViewer($user, $callType);
111
112        // Snapshots cache every section name found in each user's
113        // current_averages, including criteria-level entries ("Opening Line",
114        // "Listening Skills"), which would otherwise leak onto the radar
115        // axis. Filter them down to the top-level scorecard sections — the
116        // same whitelist DashboardMetricService applies to user sections.
117        $allowedNames = $this->allowedTopLevelSectionNames($user->id, $callType);
118        $stats['company_average'] = $this->filterSnapshotSections($averages['company'], $allowedNames);
119        $stats['global_average'] = $this->filterSnapshotSections($averages['global'], $allowedNames);
120
121        return response()->json([
122            'status' => 'success',
123            'data' => $stats,
124        ]);
125    }
126
127    /**
128     * Top-level section names the dashboard radar is allowed to render for
129     * the given user/call type. Combines the resolved scorecard config with
130     * the mechanical Call Duration Adherence row injected post-LLM.
131     *
132     * @return array<string, true> Keys are names, flipped for O(1) lookup.
133     */
134    private function allowedTopLevelSectionNames(string $userId, string $callType): array
135    {
136        $resolved = $this->scorecardResolver->resolve($userId, $callType);
137        $names = [];
138        foreach ($resolved['scorecard'] as $sectionConfig) {
139            if (is_array($sectionConfig) && ! empty($sectionConfig['name'])) {
140                $names[$sectionConfig['name']] = true;
141            }
142        }
143        $names['Call Duration Adherence'] = true;
144
145        return $names;
146    }
147
148    /**
149     * Drop snapshot section entries whose name isn't a top-level scorecard
150     * section. Returns null when no sections remain so the frontend hides
151     * the dataset entirely (matching RolePlaySectionAveragesService's
152     * empty-snapshot convention).
153     *
154     * @param  array{sections: array, user_count: int, computed_at: ?string}|null  $snapshot
155     * @param  array<string, true>  $allowed
156     * @return array{sections: array, user_count: int, computed_at: ?string}|null
157     */
158    private function filterSnapshotSections(?array $snapshot, array $allowed): ?array
159    {
160        if ($snapshot === null) {
161            return null;
162        }
163
164        $sections = array_values(array_filter(
165            $snapshot['sections'],
166            fn ($section) => is_array($section)
167                && isset($section['name'])
168                && isset($allowed[$section['name']]),
169        ));
170
171        if (empty($sections)) {
172            return null;
173        }
174
175        $snapshot['sections'] = $sections;
176
177        return $snapshot;
178    }
179
180    /**
181     * Get dashboard totals, optionally filtered by call type.
182     *
183     * @queryParam call_type string Optional call type filter (e.g., 'cold-call', 'discovery-call')
184     */
185    public function totals(Request $request): JsonResponse
186    {
187        $user = $request->user();
188        $callType = $request->query('call_type');
189
190        // Resolve the session_id / project_id sets that belong to the
191        // requested call type using the authoritative source —
192        // UserRolePlayProgression.entries — rather than the current
193        // RolePlayProjects.type field. The progression record is updated
194        // every time a session completes and records the session_id +
195        // project_id at that moment; it is the only place where the
196        // call_type association is guaranteed to survive a project rename
197        // or deletion.
198        [$sessionIds, $projectIds] = $this->metricService->resolveCallTypeScope($user, $callType);
199
200        $thisMonthStart = now()->startOfMonth();
201        $thisWeekStart = now()->startOfWeek();
202
203        $lastWeekStart = now()->startOfWeek()->subWeek();
204        $lastWeekEnd = now()->startOfWeek()->subSecond();
205
206        $lastWeekStats = $this->metricService->getConversationStatsForPeriod($user, $lastWeekStart, $lastWeekEnd, $projectIds, $sessionIds);
207        $thisWeekStats = $this->metricService->getConversationStatsForPeriod($user, $thisWeekStart, now()->endOfWeek(), $projectIds, $sessionIds);
208        $thisMonthStats = $this->metricService->getConversationStatsForPeriod($user, $thisMonthStart, now()->endOfMonth(), $projectIds, $sessionIds);
209
210        $allTimeStats = $this->metricService->getConversationStatsForPeriod($user, null, null, $projectIds, $sessionIds);
211        $allTimeStats['projectsCount'] = $projectIds !== null
212            ? count($projectIds)
213            : $this->projectsRepository->countForUser($user);
214
215        $historicalAverages = $this->metricService->getHistoricalWeeklyAverageStats($user, $projectIds, $sessionIds);
216
217        $evolution = [
218            'conversationsCount' => $this->metricService->calculateEvolution($thisWeekStats['conversationsCount'], $historicalAverages['conversationsCount']),
219            'practiceTimeSeconds' => $this->metricService->calculateEvolution($thisWeekStats['practiceTimeSeconds'], $historicalAverages['practiceTimeSeconds']),
220            'averageScore' => $this->metricService->calculateEvolution($thisWeekStats['averageScore'], $historicalAverages['averageScore']),
221        ];
222
223        $recentSessions = $this->metricService->getRecentSessions($user, $projectIds, $sessionIds);
224
225        return response()->json([
226            'status' => 'success',
227            'data' => [
228                'allTimes' => $allTimeStats,
229                'lastWeek' => $lastWeekStats,
230                'evolution' => $evolution,
231                'thisWeek' => $thisWeekStats,
232                'thisMonth' => $thisMonthStats,
233                'recent_sessions' => $recentSessions,
234            ],
235        ]);
236    }
237}