Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
54.77% covered (warning)
54.77%
132 / 241
22.22% covered (danger)
22.22%
2 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
DashboardMetricService
54.77% covered (warning)
54.77%
132 / 241
22.22% covered (danger)
22.22%
2 / 9
509.48
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getProgressionStats
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
8
 getProgressionStatsByCallType
52.89% covered (warning)
52.89%
64 / 121
0.00% covered (danger)
0.00%
0 / 1
180.11
 resolveProgressionPeriodRange
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
1.06
 getConversationStatsForPeriod
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
6.01
 getHistoricalWeeklyAverageStats
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
6
 calculateEvolution
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 resolveCallTypeScope
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 getRecentSessions
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace App\Services\RolePlay;
4
5use App\Http\Models\Auth\User;
6use App\Http\Models\RolePlayConversations;
7use App\Http\Models\RolePlayProjects;
8use App\Http\Models\RolePlaySkillProgressions;
9use App\Http\Models\UserRolePlayProgression;
10use App\Http\Services\ScorecardResolverService;
11use Carbon\Carbon;
12use MongoDB\BSON\UTCDateTime;
13
14class DashboardMetricService
15{
16    protected RolePlayConversations $conversationModel;
17
18    protected RolePlaySkillProgressions $skillProgressionModel;
19
20    protected RolePlayProjects $projectModel;
21
22    protected ScorecardResolverService $scorecardResolver;
23
24    public function __construct(
25        RolePlayConversations $conversationModel,
26        RolePlaySkillProgressions $skillProgressionModel,
27        RolePlayProjects $projectModel,
28        ScorecardResolverService $scorecardResolver
29    ) {
30        $this->conversationModel = $conversationModel;
31        $this->skillProgressionModel = $skillProgressionModel;
32        $this->projectModel = $projectModel;
33        $this->scorecardResolver = $scorecardResolver;
34    }
35
36    public function getProgressionStats(User $user): array
37    {
38        $skillProgressions = $this->skillProgressionModel->where('status', 'active')->get();
39
40        $criterias = [];
41
42        foreach ($skillProgressions as $progression) {
43            if (empty($progression->scorecard_config) || ! is_array($progression->scorecard_config)) {
44                continue;
45            }
46
47            foreach ($progression->scorecard_config as $config) {
48                $projects = $this->projectModel->where('user_id', $user->id)->where('type', $progression->type)->get();
49
50                foreach ($projects as $project) {
51                    $progressions = $project->progression ?? [];
52
53                    $progress = collect($progressions)->first(function ($prog) use ($config) {
54                        return $prog['name'] === $config['name'];
55                    });
56
57                    $existentCriteria = collect($criterias)->first(function ($crit) use ($config, $progression) {
58                        return $crit['name'] === $config['name'] && $crit['type'] === $progression->type;
59                    });
60
61                    if ($existentCriteria) {
62                        $existentCriteria['score'] = ($existentCriteria['score'] + ($progress['score'] ?? 0)) / 2;
63                    } else {
64                        $criterias[] = [
65                            'type' => $progression->type,
66                            'name' => $config['name'],
67                            'score' => $progress['score'] ?? 0,
68                        ];
69                    }
70                }
71            }
72        }
73
74        return $criterias;
75    }
76
77    /**
78     * Get progression stats for a specific user and call type from UserRolePlayProgression.
79     *
80     * Returns overall score, per-section scores, trend data (last 20 sessions),
81     * session count, and last session timestamp. When a period is supplied,
82     * entries are filtered by date window and sections/overall are recomputed
83     * as straight means over the filtered window — aligning the scorecard
84     * breakdown, radar, and trend with the top-card Success Rate (also a
85     * plain mean for the selected period). Without a period filter the
86     * response falls back to the pre-computed EMA averages for backwards
87     * compatibility with existing consumers.
88     *
89     * @param  string  $userId  The user's ID
90     * @param  string  $callType  The call type (e.g., 'cold-call', 'discovery-call')
91     * @param  string|null  $period  Optional period filter: 'thisWeek', 'thisMonth', or 'allTimes'
92     * @return array{overall: float, sections: array, trend: array, session_count: int, last_session: string|null}
93     */
94    public function getProgressionStatsByCallType(string $userId, string $callType, ?string $period = null): array
95    {
96        $progression = UserRolePlayProgression::where('user_id', $userId)
97            ->where('call_type', $callType)
98            ->first();
99
100        if (! $progression) {
101            return ['overall' => 0, 'sections' => [], 'trend' => [], 'session_count' => 0, 'last_session' => null];
102        }
103
104        // Build a name → weight lookup from the user's resolved scorecard so
105        // the dashboard can render "earned / weight" denominators instead of
106        // a hardcoded "/100". The resolver walks the company/user/system
107        // hierarchy so this stays consistent with how scores are calculated.
108        $resolved = $this->scorecardResolver->resolve($userId, $callType);
109        $weightByName = [];
110        foreach ($resolved['scorecard'] as $sectionConfig) {
111            $name = $sectionConfig['name'] ?? null;
112            if ($name !== null && isset($sectionConfig['weight'])) {
113                $weightByName[$name] = (float) $sectionConfig['weight'];
114            }
115        }
116        // Call Duration Adherence is a mechanical section injected post-LLM
117        // (not part of the scorecard config). Include it so the dashboard
118        // renders the section with its proper weight denominator.
119        $weightByName['Call Duration Adherence'] = (float) \App\Jobs\ProcessRolePlaySessionAsyncJob::DURATION_ADHERENCE_WEIGHT;
120
121        [$startDate, $endDate] = $this->resolveProgressionPeriodRange($period);
122
123        // Resolve each entry's authoritative date from the underlying
124        // role_play_conversations record. Progression entries store
125        // `date = now()` at append time, so any backfill/reprocess makes
126        // all entries appear "recent" and breaks period filtering.
127        // We look up the real created_at via session_id and annotate the
128        // entries with `_resolved_date` (a Carbon instance) for sorting,
129        // filtering, trend rendering and last_session reporting.
130        $entries = collect($progression->entries ?? []);
131        $sessionIds = $entries->pluck('session_id')
132            ->filter()
133            ->map(fn ($id) => (string) $id)
134            ->unique()
135            ->values()
136            ->all();
137
138        $conversationDates = [];
139        if (! empty($sessionIds)) {
140            $conversations = $this->conversationModel
141                ->whereIn('_id', $sessionIds)
142                ->get(['_id', 'created_at']);
143            foreach ($conversations as $conv) {
144                $conversationDates[(string) $conv->_id] = $conv->created_at
145                    ? Carbon::parse($conv->created_at)
146                    : null;
147            }
148        }
149
150        $allEntries = $entries->map(function ($entry) use ($conversationDates) {
151            $sessionId = isset($entry['session_id']) ? (string) $entry['session_id'] : null;
152            $resolved = $sessionId && isset($conversationDates[$sessionId])
153                ? $conversationDates[$sessionId]
154                : null;
155            if ($resolved === null && ! empty($entry['date'])) {
156                try {
157                    $resolved = Carbon::parse($entry['date']);
158                } catch (\Exception $e) {
159                    $resolved = null;
160                }
161            }
162            $entry['_resolved_date'] = $resolved;
163
164            return $entry;
165        })
166            ->sortBy(fn ($e) => $e['_resolved_date'] ? $e['_resolved_date']->timestamp : 0)
167            ->values();
168
169        $filteredEntries = ($startDate || $endDate)
170            ? $allEntries->filter(function ($entry) use ($startDate, $endDate) {
171                $date = $entry['_resolved_date'] ?? null;
172                if ($date === null) {
173                    return false;
174                }
175                if ($startDate && $date->lt($startDate)) {
176                    return false;
177                }
178                if ($endDate && $date->gt($endDate)) {
179                    return false;
180                }
181
182                return true;
183            })->values()
184            : $allEntries;
185
186        if ($period !== null) {
187            $sectionsAgg = [];
188            $overallSum = 0.0;
189            $overallCount = 0;
190            foreach ($filteredEntries as $entry) {
191                $overallSum += (float) ($entry['overall_score'] ?? 0);
192                $overallCount++;
193                foreach (($entry['sections'] ?? []) as $section) {
194                    $name = $section['name'] ?? null;
195                    if ($name === null) {
196                        continue;
197                    }
198                    if (! isset($sectionsAgg[$name])) {
199                        $sectionsAgg[$name] = ['sum' => 0.0, 'count' => 0];
200                    }
201                    $sectionsAgg[$name]['sum'] += (float) ($section['score'] ?? 0);
202                    $sectionsAgg[$name]['count']++;
203                }
204            }
205
206            $overall = $overallCount > 0 ? round($overallSum / $overallCount, 1) : 0;
207
208            // Preserve scorecard config order; append any unknown sections at
209            // the end so new/removed config doesn't silently drop data.
210            $sections = [];
211            foreach (array_keys($weightByName) as $name) {
212                if (isset($sectionsAgg[$name])) {
213                    $agg = $sectionsAgg[$name];
214                    $sections[] = [
215                        'name' => $name,
216                        'score' => round($agg['sum'] / $agg['count'], 1),
217                        'weight' => $weightByName[$name],
218                    ];
219                    unset($sectionsAgg[$name]);
220                }
221            }
222            // Only include remaining entries that match a known section name.
223            // Criteria-level entries (e.g. "Opening Line", "Listening Skills")
224            // leak into progression data but should NOT appear in the dashboard
225            // breakdown — only top-level sections belong there.
226            foreach ($sectionsAgg as $name => $agg) {
227                if (! isset($weightByName[$name])) {
228                    continue;
229                }
230                $sections[] = [
231                    'name' => $name,
232                    'score' => round($agg['sum'] / $agg['count'], 1),
233                    'weight' => $weightByName[$name],
234                ];
235            }
236        } else {
237            $current = $progression->current_averages ?? [];
238            $sections = collect($current['sections'] ?? [])
239                ->filter(fn ($score, $name) => isset($weightByName[$name]))
240                ->map(fn ($score, $name) => [
241                    'name' => $name,
242                    'score' => round($score, 1),
243                    'weight' => $weightByName[$name],
244                ])
245                ->values()
246                ->all();
247            $overall = round($current['overall'] ?? 0, 1);
248        }
249
250        $trendEntries = $filteredEntries->take(-20)->values();
251        $trend = $trendEntries->map(fn ($e) => [
252            'date' => $e['_resolved_date'] ? $e['_resolved_date']->toIso8601String() : ($e['date'] ?? null),
253            'score' => $e['overall_score'],
254        ])->all();
255
256        $sessionCount = $period !== null
257            ? $filteredEntries->count()
258            : ($progression->session_count ?? 0);
259
260        $lastFiltered = $filteredEntries->last();
261        $lastSession = $period !== null
262            ? ($lastFiltered && $lastFiltered['_resolved_date']
263                ? $lastFiltered['_resolved_date']->toIso8601String()
264                : ($lastFiltered['date'] ?? null))
265            : $progression->last_session_at;
266
267        return [
268            'overall' => $overall,
269            'sections' => $sections,
270            'trend' => $trend,
271            'session_count' => $sessionCount,
272            'last_session' => $lastSession,
273        ];
274    }
275
276    /**
277     * Resolve the [$start, $end] Carbon window for a progression period filter.
278     *
279     * @param  string|null  $period  'thisWeek', 'thisMonth', 'allTimes', or null
280     * @return array{0: \Carbon\Carbon|null, 1: \Carbon\Carbon|null}
281     */
282    private function resolveProgressionPeriodRange(?string $period): array
283    {
284        return match ($period) {
285            'thisWeek' => [now()->startOfWeek(), now()->endOfWeek()],
286            'thisMonth' => [now()->startOfMonth(), now()->endOfMonth()],
287            default => [null, null],
288        };
289    }
290
291    /**
292     * @param  array<string>|null  $projectIds  Optional project IDs to filter by (for call type filtering)
293     * @param  array<string>|null  $sessionIds  Optional conversation IDs to filter by. When provided,
294     *                                          takes precedence over $projectIds — use this for
295     *                                          progression-driven call_type filtering that remains
296     *                                          stable across persona renames/deletions.
297     * @return array{conversationsCount: int, practiceTimeSeconds: int|float, averageScore: float}
298     */
299    public function getConversationStatsForPeriod(User $user, ?Carbon $startDate, ?Carbon $endDate, ?array $projectIds = null, ?array $sessionIds = null): array
300    {
301        $query = $this->conversationModel->where('user_id', $user->id);
302
303        if ($sessionIds !== null) {
304            $query->whereIn('_id', $sessionIds);
305        } elseif ($projectIds !== null) {
306            $query->whereIn('project_id', $projectIds);
307        }
308
309        if ($startDate && $endDate) {
310            $query->where('created_at', '>=', new UTCDateTime(strtotime($startDate->toDateString()) * 1000))
311                ->where('created_at', '<=', new UTCDateTime(strtotime($endDate->toDateString()) * 1000));
312        } elseif ($endDate) {
313            $query->where('created_at', '<', new UTCDateTime(strtotime($endDate->toDateString()) * 1000));
314        }
315
316        return [
317            'conversationsCount' => $query->clone()->count() ?? 0,
318            'practiceTimeSeconds' => $query->clone()->sum('duration') ?? 0,
319            'averageScore' => round($query->clone()->avg('score') ?? 0, 2),
320        ];
321    }
322
323    /**
324     * @param  array<string>|null  $projectIds  Optional project IDs to filter by (for call type filtering)
325     * @param  array<string>|null  $sessionIds  Optional conversation IDs to filter by. Takes precedence
326     *                                          over $projectIds when set (progression-driven filtering).
327     * @return array{conversationsCount: float, practiceTimeSeconds: float, averageScore: float}
328     */
329    public function getHistoricalWeeklyAverageStats(User $user, ?array $projectIds = null, ?array $sessionIds = null): array
330    {
331        $historicalQuery = $this->conversationModel->where('user_id', $user->id)
332            ->where('created_at', '<', now()->startOfWeek()->subWeek());
333
334        if ($sessionIds !== null) {
335            $historicalQuery->whereIn('_id', $sessionIds);
336        } elseif ($projectIds !== null) {
337            $historicalQuery->whereIn('project_id', $projectIds);
338        }
339
340        $firstRecord = $historicalQuery->clone()->orderBy('created_at', 'asc')->first();
341
342        if (! $firstRecord) {
343            return [
344                'conversationsCount' => 0,
345                'practiceTimeSeconds' => 0,
346                'averageScore' => 0,
347            ];
348        }
349
350        $lastRecordDate = $historicalQuery->clone()->orderBy('created_at', 'desc')->first()->created_at;
351        $totalWeeks = ($firstRecord->created_at->diffInWeeks($lastRecordDate) ?? 0) + 1;
352
353        $totalConversations = $historicalQuery->clone()->count() ?? 0;
354        $totalPracticeTime = $historicalQuery->clone()->sum('duration') ?? 0;
355
356        return [
357            'conversationsCount' => $totalWeeks > 0 ? $totalConversations / $totalWeeks : 0,
358            'practiceTimeSeconds' => $totalWeeks > 0 ? $totalPracticeTime / $totalWeeks : 0,
359            'averageScore' => round($historicalQuery->clone()->avg('score') ?? 0, 2),
360        ];
361    }
362
363    public function calculateEvolution(float $current, float $previous): float
364    {
365        if ($previous == 0) {
366            return $current > 0 ? 100.00 : 0.00;
367        }
368
369        $evolution = (($current - $previous) / $previous) * 100;
370
371        return round($evolution, 2);
372    }
373
374    /**
375     * Resolve the session_id / project_id sets that belong to a given
376     * call_type for a user, from the authoritative UserRolePlayProgression
377     * record (not the live RolePlayProjects table — projects can be renamed
378     * or soft-deleted and the totals must still match /progression).
379     *
380     * Returns [null, null] when callType is empty or no progression exists,
381     * which the caller should interpret as "no call-type scoping".
382     *
383     * @return array{0: array<int, string>|null, 1: array<int, string>|null}
384     */
385    public function resolveCallTypeScope(User $user, ?string $callType): array
386    {
387        if (! $callType) {
388            return [null, null];
389        }
390
391        $progression = UserRolePlayProgression::where('user_id', $user->id)
392            ->where('call_type', $callType)
393            ->first();
394
395        if (! $progression) {
396            return [[], []];
397        }
398
399        $entries = collect($progression->entries ?? []);
400
401        $sessionIds = $entries->pluck('session_id')
402            ->filter()
403            ->map(fn ($id) => (string) $id)
404            ->unique()
405            ->values()
406            ->all();
407
408        $projectIds = $entries->pluck('project_id')
409            ->filter()
410            ->map(fn ($id) => (string) $id)
411            ->unique()
412            ->values()
413            ->all();
414
415        return [$sessionIds, $projectIds];
416    }
417
418    /**
419     * Fetch the most recent completed roleplay sessions for the dashboard
420     * "Recent Sessions" card, projected into the FE-consumed shape.
421     *
422     * @param  array<int, string>|null  $projectIds  Optional project id filter.
423     * @param  array<int, string>|null  $sessionIds  Optional session id filter (takes precedence).
424     * @return array<int, array{id:string,project_name:string,call_type:string,score:float,duration:int,created_at:string}>
425     */
426    public function getRecentSessions(User $user, ?array $projectIds = null, ?array $sessionIds = null, int $limit = 5): array
427    {
428        $query = RolePlayConversations::where('user_id', $user->id)
429            ->where('status', 'done');
430
431        if ($sessionIds !== null) {
432            $query->whereIn('_id', $sessionIds);
433        } elseif ($projectIds !== null) {
434            $query->whereIn('project_id', $projectIds);
435        }
436
437        $conversations = $query->orderBy('created_at', 'desc')
438            ->limit($limit)
439            ->get();
440
441        if ($conversations->isEmpty()) {
442            return [];
443        }
444
445        $projectLookup = RolePlayProjects::withTrashed()->whereIn(
446            '_id',
447            $conversations->pluck('project_id')->filter()->unique()->values()->all()
448        )->get()->keyBy(fn ($p) => (string) $p->_id);
449
450        return $conversations->map(function ($conversation) use ($projectLookup) {
451            $project = $projectLookup->get((string) $conversation->project_id);
452
453            return [
454                'id' => (string) $conversation->_id,
455                'project_name' => $project->name ?? 'Untitled',
456                'call_type' => $project->type ?? '',
457                'score' => (float) ($conversation->score ?? 0),
458                'duration' => (int) ($conversation->duration ?? 0),
459                'created_at' => optional($conversation->created_at)->toIso8601String() ?? '',
460            ];
461        })->all();
462    }
463}