Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.04% covered (success)
98.04%
50 / 51
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserRolePlayProgression
98.04% covered (success)
98.04%
50 / 51
50.00% covered (danger)
50.00%
1 / 2
16
0.00% covered (danger)
0.00%
0 / 1
 appendEntry
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
4
 dropEntriesAndRecompute
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace App\Http\Models;
4
5/**
6 * UserRolePlayProgression Model
7 *
8 * Tracks per-user, per-call-type roleplay progression over time.
9 * Stores individual session entries and maintains running EMA averages
10 * for overall score and per-section scores.
11 *
12 * @property string $user_id The user's ID
13 * @property string $call_type The call type (e.g., 'cold-call', 'discovery-call')
14 * @property array $entries Array of session entry records
15 * @property array $current_averages Running EMA averages {overall: float, sections: {name: float}}
16 * @property int $session_count Total number of sessions recorded
17 * @property string|null $last_session_at ISO 8601 timestamp of the last session
18 * @property \Carbon\Carbon|null $created_at
19 * @property \Carbon\Carbon|null $updated_at
20 */
21class UserRolePlayProgression extends Moloquent
22{
23    /**
24     * The database table used by the model.
25     *
26     * @var string
27     */
28    protected $table = 'user_role_play_progressions';
29
30    /**
31     * The attributes that are mass assignable.
32     *
33     * @var array<int, string>
34     */
35    protected $fillable = [
36        'user_id',
37        'call_type',
38        'entries',
39        'current_averages',
40        'session_count',
41        'last_session_at',
42    ];
43
44    /**
45     * The attributes that should be cast.
46     *
47     * @var array<string, string>
48     */
49    protected $casts = [
50        'entries' => 'array',
51        'current_averages' => 'array',
52    ];
53
54    /**
55     * Append a new session entry and update the running EMA averages.
56     *
57     * @param  array  $sessionData  Session data containing session_id, project_id, overall_score, and sections
58     * @param  float  $alpha  EMA smoothing factor (0 < alpha <= 1). Higher values weight recent sessions more.
59     */
60    public function appendEntry(array $sessionData, float $alpha = 0.3): void
61    {
62        $entry = [
63            'session_id' => $sessionData['session_id'],
64            'project_id' => $sessionData['project_id'],
65            'date' => now()->toIso8601String(),
66            'overall_score' => $sessionData['overall_score'],
67            'sections' => $sessionData['sections'],
68        ];
69
70        $entries = $this->entries ?? [];
71        $entries[] = $entry;
72        $this->entries = $entries;
73
74        $current = $this->current_averages ?? [];
75        $current['overall'] = isset($current['overall'])
76            ? $alpha * $entry['overall_score'] + (1 - $alpha) * $current['overall']
77            : $entry['overall_score'];
78
79        foreach ($entry['sections'] as $section) {
80            $key = $section['name'];
81            $current['sections'][$key] = isset($current['sections'][$key])
82                ? $alpha * $section['score'] + (1 - $alpha) * $current['sections'][$key]
83                : $section['score'];
84        }
85
86        $this->current_averages = $current;
87        $this->session_count = count($entries);
88        $this->last_session_at = now()->toIso8601String();
89        $this->save();
90    }
91
92    /**
93     * Remove entries matching the given session IDs (or all entries belonging
94     * to the given project IDs) and fully recompute current_averages by
95     * replaying the EMA logic over the surviving entries in chronological
96     * order. Used after a user deletes a short session or an entire persona.
97     *
98     * @param  array<int, string>  $sessionIds  Session IDs to drop (may be empty)
99     * @param  array<int, string>  $projectIds  Project IDs whose entries should all be dropped (may be empty)
100     * @param  float  $alpha  EMA smoothing factor, must match appendEntry()
101     */
102    public function dropEntriesAndRecompute(array $sessionIds = [], array $projectIds = [], float $alpha = 0.3): void
103    {
104        $entries = $this->entries ?? [];
105
106        $remaining = array_values(array_filter($entries, function ($entry) use ($sessionIds, $projectIds) {
107            if (! empty($sessionIds) && in_array((string) ($entry['session_id'] ?? ''), $sessionIds, true)) {
108                return false;
109            }
110            if (! empty($projectIds) && in_array((string) ($entry['project_id'] ?? ''), $projectIds, true)) {
111                return false;
112            }
113
114            return true;
115        }));
116
117        $averages = ['overall' => null, 'sections' => []];
118
119        foreach ($remaining as $entry) {
120            $averages['overall'] = $averages['overall'] === null
121                ? ($entry['overall_score'] ?? 0)
122                : $alpha * ($entry['overall_score'] ?? 0) + (1 - $alpha) * $averages['overall'];
123
124            foreach (($entry['sections'] ?? []) as $section) {
125                $key = $section['name'] ?? null;
126                if ($key === null) {
127                    continue;
128                }
129                $score = (float) ($section['score'] ?? 0);
130                $averages['sections'][$key] = isset($averages['sections'][$key])
131                    ? $alpha * $score + (1 - $alpha) * $averages['sections'][$key]
132                    : $score;
133            }
134        }
135
136        $this->entries = $remaining;
137        $this->current_averages = empty($remaining) ? null : $averages;
138        $this->session_count = count($remaining);
139        $this->last_session_at = empty($remaining)
140            ? null
141            : ($remaining[count($remaining) - 1]['date'] ?? null);
142        $this->save();
143    }
144}