Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.00% covered (warning)
85.00%
102 / 120
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
CalendarWidgetDataLoader
84.87% covered (warning)
84.87%
101 / 119
36.36% covered (danger)
36.36%
4 / 11
45.54
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
 getCurrentCalendarEvents
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 getCurrentCalendarEventsCount
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
3.04
 getOverdueCalendarEvents
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
3.02
 getOverdueCalendarEventsCount
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
3.04
 mapCalendarRows
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
9.03
 tableExists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatOverdueInterval
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
9.79
 buildCalendarQueryParts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatSimplifiedDate
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
7.93
 formatStandardCalendarDate
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Dashboard\Application\Service\Widget;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Dashboard\Application\Service\Query\CalendarEventQueryBuilder;
12use DateTimeImmutable;
13use PDO;
14use Throwable;
15
16/**
17 * Enterprise Calendar Widget Data Loader.
18 *
19 * Queries and formats ongoing, upcoming, and overdue calendar events with relational links
20 * to companies, processes (projects/contracts), and subprocesses (tickets/tasks).
21 *
22 * @package App\Modules\Dashboard\Application\Service\Widget
23 */
24final readonly class CalendarWidgetDataLoader
25{
26    private const string PARAM_USER_ID = ':user_id';
27
28    private CalendarEventQueryBuilder $queryBuilder;
29
30    /**
31     * CalendarWidgetDataLoader constructor.
32     *
33     * @param string                         $tablePrefix  Database table prefix.
34     * @param CalendarEventQueryBuilder|null $queryBuilder Optional calendar query builder.
35     */
36    public function __construct(
37        private string $tablePrefix = 'a_',
38        ?CalendarEventQueryBuilder $queryBuilder = null
39    ) {
40        $this->queryBuilder = $queryBuilder ?? new CalendarEventQueryBuilder($this->tablePrefix);
41    }
42
43    /**
44     * Returns current ongoing or upcoming calendar events.
45     *
46     * @param PDO $pdo    Active PDO handle.
47     * @param int $userId Target user ID.
48     * @param int $limit  Maximum records to return.
49     * @return array<int, array<string, mixed>> List of current events.
50     */
51    public function getCurrentCalendarEvents(PDO $pdo, int $userId, int $limit = 5): array
52    {
53        $calTable = $this->tablePrefix . 'mod_calendar_records';
54        if (!$this->tableExists($pdo, $calTable)) {
55            return [];
56        }
57
58        [$selectSql, $joinSql] = $this->buildCalendarQueryParts($pdo);
59
60        $sql = "SELECT {$selectSql}
61                FROM   `{$calTable}` cal
62                {$joinSql}
63                WHERE  cal.`owner` = :user_id
64                  AND  cal.`status` IN ('planned', 'in_progress')
65                  AND  (cal.`end_date` >= NOW() OR (cal.`end_date` IS NULL AND cal.`start_date` >= CURDATE()))
66                ORDER BY cal.`start_date` ASC
67                LIMIT  :limit";
68
69        try {
70            $stmt = $pdo->prepare($sql);
71            $stmt->bindValue(self::PARAM_USER_ID, $userId, PDO::PARAM_INT);
72            $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
73            $stmt->execute();
74            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
75
76            return $this->mapCalendarRows($rows, false);
77        } catch (Throwable) {
78            return [];
79        }
80    }
81
82    /**
83     * Returns total count of current ongoing or upcoming calendar events.
84     *
85     * @param PDO $pdo    Active PDO handle.
86     * @param int $userId Target user ID.
87     * @return int Total count of upcoming calendar events.
88     */
89    public function getCurrentCalendarEventsCount(PDO $pdo, int $userId): int
90    {
91        $calTable = $this->tablePrefix . 'mod_calendar_records';
92        if (!$this->tableExists($pdo, $calTable)) {
93            return 0;
94        }
95
96        $sql = "SELECT COUNT(*)
97                FROM   `{$calTable}` cal
98                WHERE  cal.`owner` = :user_id
99                  AND  cal.`status` IN ('planned', 'in_progress')
100                  AND  (cal.`end_date` >= NOW() OR (cal.`end_date` IS NULL AND cal.`start_date` >= CURDATE()))";
101
102        try {
103            $stmt = $pdo->prepare($sql);
104            $stmt->bindValue(self::PARAM_USER_ID, $userId, PDO::PARAM_INT);
105            $stmt->execute();
106
107            return (int) $stmt->fetchColumn();
108        } catch (Throwable) {
109            return 0;
110        }
111    }
112
113    /**
114     * Returns overdue calendar events.
115     *
116     * @param PDO $pdo    Active PDO handle.
117     * @param int $userId Target user ID.
118     * @param int $limit  Maximum records to return.
119     * @return array<int, array<string, mixed>> List of overdue events.
120     */
121    public function getOverdueCalendarEvents(PDO $pdo, int $userId, int $limit = 5): array
122    {
123        $calTable = $this->tablePrefix . 'mod_calendar_records';
124        if (!$this->tableExists($pdo, $calTable)) {
125            return [];
126        }
127
128        [$selectSql, $joinSql] = $this->buildCalendarQueryParts($pdo);
129
130        $sql = "SELECT {$selectSql}
131                FROM   `{$calTable}` cal
132                {$joinSql}
133                WHERE  cal.`owner` = :user_id
134                  AND  (
135                         cal.`status` = 'overdue'
136                         OR (cal.`status` IN ('planned', 'in_progress') AND cal.`end_date` < NOW())
137                       )
138                ORDER BY cal.`end_date` ASC
139                LIMIT  :limit";
140
141        try {
142            $stmt = $pdo->prepare($sql);
143            $stmt->bindValue(self::PARAM_USER_ID, $userId, PDO::PARAM_INT);
144            $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
145            $stmt->execute();
146            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
147
148            return $this->mapCalendarRows($rows, true);
149        } catch (Throwable) {
150            return [];
151        }
152    }
153
154    /**
155     * Returns total count of overdue calendar events.
156     *
157     * @param PDO $pdo    Active PDO handle.
158     * @param int $userId Target user ID.
159     * @return int Total count of overdue calendar events.
160     */
161    public function getOverdueCalendarEventsCount(PDO $pdo, int $userId): int
162    {
163        $calTable = $this->tablePrefix . 'mod_calendar_records';
164        if (!$this->tableExists($pdo, $calTable)) {
165            return 0;
166        }
167
168        $sql = "SELECT COUNT(*)
169                FROM   `{$calTable}` cal
170                WHERE  cal.`owner` = :user_id
171                  AND  (
172                         cal.`status` = 'overdue'
173                         OR (cal.`status` IN ('planned', 'in_progress') AND cal.`end_date` < NOW())
174                       )";
175
176        try {
177            $stmt = $pdo->prepare($sql);
178            $stmt->bindValue(self::PARAM_USER_ID, $userId, PDO::PARAM_INT);
179            $stmt->execute();
180
181            return (int) $stmt->fetchColumn();
182        } catch (Throwable) {
183            return 0;
184        }
185    }
186
187    /**
188     * Maps raw database rows into formatted event records with relative URLs.
189     *
190     * @param array<int, array<string, mixed>> $rows      Raw SQL result rows.
191     * @param bool                             $isOverdue Whether mapping overdue events.
192     * @return array<int, array<string, mixed>> Formatted event records.
193     */
194    private function mapCalendarRows(array $rows, bool $isOverdue): array
195    {
196        return array_map(function (array $row) use ($isOverdue): array {
197            $rawEnd = (string) ($row['end_date'] ?? '');
198            $rawStart = (string) ($row['start_date'] ?? '');
199            $displayDate = ($isOverdue && $rawEnd !== '') ? $rawEnd : $rawStart;
200
201            if ($isOverdue) {
202                $row['overdue_label'] = $this->formatOverdueInterval($rawEnd);
203            }
204            $row['simplified_date'] = $this->formatSimplifiedDate($displayDate);
205            $row['process_name'] = trim((string) ($row['process_name'] ?? ''));
206            $row['subprocess_name'] = trim((string) ($row['subprocess_name'] ?? ''));
207
208            $companyId = (int) ($row['company_id'] ?? 0);
209            $row['company_url'] = $companyId > 0 ? "/companies/{$companyId}" : null;
210
211            $projectId = (int) ($row['project_id'] ?? 0);
212            $contractId = (int) ($row['contract_id'] ?? 0);
213            if ($projectId > 0) {
214                $row['process_url'] = "/projects/{$projectId}";
215            } elseif ($contractId > 0) {
216                $row['process_url'] = "/contracts/{$contractId}";
217            } else {
218                $row['process_url'] = null;
219            }
220
221            $ticketId = (int) ($row['ticket_id'] ?? 0);
222            $taskId = (int) ($row['task_id'] ?? 0);
223            if ($ticketId > 0) {
224                $row['subprocess_url'] = "/tickets/{$ticketId}";
225            } elseif ($taskId > 0) {
226                $row['subprocess_url'] = "/project-tasks/{$taskId}";
227            } else {
228                $row['subprocess_url'] = null;
229            }
230
231            return $row;
232        }, $rows);
233    }
234
235    /**
236     * Checks if a database table exists in the target database.
237     *
238     * @param PDO    $pdo       Database connection.
239     * @param string $tableName Table name.
240     * @return bool True if table exists.
241     */
242    public function tableExists(PDO $pdo, string $tableName): bool
243    {
244        return $this->queryBuilder->tableExists($pdo, $tableName);
245    }
246
247    /**
248     * Formats human-readable overdue time string.
249     *
250     * @param string $endDate Raw end datetime string.
251     * @return string Human-friendly overdue label.
252     */
253    public function formatOverdueInterval(string $endDate): string
254    {
255        if ($endDate === '') {
256            return 'Przekroczony termin';
257        }
258
259        try {
260            $target = new DateTimeImmutable($endDate);
261            $now = new DateTimeImmutable();
262            $diff = $now->diff($target);
263
264            $label = 'Expired just now';
265            if ($diff->days > 0) {
266                $label = sprintf('Expired %d %s ago', $diff->days, $diff->days === 1 ? 'day' : 'days');
267            } elseif ($diff->h > 0) {
268                $label = sprintf('Expired %d %s ago', $diff->h, $diff->h === 1 ? 'hour' : 'hours');
269            }
270
271            return $label;
272        } catch (Throwable) {
273            return 'Past deadline';
274        }
275    }
276
277    /**
278     * Builds SELECT and JOIN clauses for calendar queries including company, process, and subprocess relations.
279     *
280     * @param PDO $pdo Database connection.
281     * @return array{0: string, 1: string} [selectSql, joinSql]
282     */
283    public function buildCalendarQueryParts(PDO $pdo): array
284    {
285        return $this->queryBuilder->buildCalendarQueryParts($pdo, false);
286    }
287
288    /**
289     * Formats human-readable simplified datetime string.
290     *
291     * @param string $datetime Raw ISO datetime string.
292     * @return string Simplified date (e.g. 'Today, 14:00', 'Tomorrow, 09:30', '14 Sep, 00:21').
293     */
294    public function formatSimplifiedDate(string $datetime): string
295    {
296        if ($datetime === '') {
297            return '';
298        }
299
300        try {
301            $dt = new DateTimeImmutable($datetime);
302            $today = (new DateTimeImmutable())->setTime(0, 0, 0);
303            $dtDay = $dt->setTime(0, 0, 0);
304            $diffDays = (int) $today->diff($dtDay)->format('%r%a');
305            $time = $dt->format('H:i');
306
307            return match ($diffDays) {
308                0 => sprintf('Today, %s', $time),
309                1 => sprintf('Tomorrow, %s', $time),
310                -1 => sprintf('Yesterday, %s', $time),
311                default => $this->formatStandardCalendarDate($dt, $today, $time),
312            };
313        } catch (Throwable) {
314            return substr($datetime, 0, 16);
315        }
316    }
317
318    private function formatStandardCalendarDate(
319        DateTimeImmutable $dt,
320        DateTimeImmutable $today,
321        string $time
322    ): string {
323        $monthName = $dt->format('M');
324        $day = (int) $dt->format('j');
325        if ($dt->format('Y') === $today->format('Y')) {
326            return sprintf('%d %s, %s', $day, $monthName, $time);
327        }
328
329        return sprintf('%d %s %s, %s', $day, $monthName, $dt->format('Y'), $time);
330    }
331}