PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / trunk
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration vtrunk
2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 1.41 1.45 All 41 releases
fluent-boards / app / Services / ReportService.php

ReportService.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration trunk, at app/Services/ReportService.php

1,390 lines 46.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBoards\App\Services;
4
5 use FluentBoards\App\Models\Activity;
6 use FluentBoards\App\Models\Board;
7 use FluentBoards\App\Models\BoardTerm;
8 use FluentBoards\App\Models\Relation;
9 use FluentBoards\App\Models\Task;
10 use FluentBoards\App\Models\TaskMeta;
11 use FluentBoards\App\Models\User;
12
13 /**
14 * Aggregates for the Reports screens (Overview / Tasks / Activity).
15 *
16 * Every payload here matches the prop shapes the report widgets were written
17 * against, so the screens hand a response straight to their widgets.
18 *
19 * Date range semantics — a task belongs to a range when it was *touched* in it:
20 * created, completed or updated inside the window. `overdue` is the one
21 * deliberate exception: it is always a reading of "overdue right now", because a
22 * historical overdue count is not actionable.
23 *
24 * See docs/plan/reports-real-data.md.
25 */
26 class ReportService
27 {
28 /**
29 * Priority buckets, in render order. The empty key covers tasks with no
30 * priority set (stored as NULL or '').
31 */
32 const PRIORITY_BUCKETS = [
33 'urgent' => 'Urgent',
34 'high' => 'High',
35 'medium' => 'Medium',
36 'low' => 'Low',
37 'none' => 'None',
38 ];
39
40 /**
41 * Report key => [column, [actions]] against `fbs_activities`.
42 *
43 * Columns/actions are the literals ActivityHandler writes; `null` actions
44 * means "any action on this column".
45 */
46 const ACTIVITY_TYPES = [
47 'task_stage_updated' => ['label' => 'Stage Change', 'icon' => 'task-stage', 'column' => 'stage', 'actions' => null],
48 'comment_created' => ['label' => 'Comments', 'icon' => 'comment-line', 'column' => 'comment', 'actions' => ['added']],
49 'task_created' => ['label' => 'Task Created', 'icon' => 'plus', 'column' => 'task', 'actions' => ['created']],
50 'subtask_added' => ['label' => 'Subtasks', 'icon' => 'subtask', 'column' => 'subtask', 'actions' => ['added', 'cloned']],
51 'task_attachment_added' => ['label' => 'Attachments', 'icon' => 'paper-clip', 'column' => 'attachment', 'actions' => ['added']],
52 'task_label' => ['label' => 'Labels', 'icon' => 'label', 'column' => 'label', 'actions' => null],
53 'task_due_date_changed' => ['label' => 'Due Dates', 'icon' => 'date', 'column' => 'Due Date', 'actions' => null],
54 ];
55
56 /**
57 * How many rows the "top N" panels return.
58 */
59 const TOP_ROWS = 8;
60
61 /**
62 * Daily roadmap charts must remain bounded to avoid oversized responses.
63 */
64 const MAX_ROADMAP_REPORT_DAYS = 366;
65
66 /**
67 * Resolves the shared `board_id` / `start_date` / `end_date` parameters into
68 * a scope every report method runs against.
69 *
70 * A board the user cannot access simply yields an empty board list, so the
71 * screens render their empty states instead of an error.
72 *
73 * @param array $filters ['board_id' => int|null, 'start_date' => string|null, 'end_date' => string|null]
74 * @return array{boardIds: array, start: string, end: string}
75 */
76 public function resolveScope($filters = [])
77 {
78 return $this->resolveBoardScope($filters, 'to-do');
79 }
80
81 /**
82 * Resolves the shared filters against accessible roadmap boards.
83 *
84 * @param array $filters
85 * @return array{boardIds: array, start: string, end: string}
86 */
87 public function resolveRoadmapScope($filters = [])
88 {
89 $scope = $this->resolveBoardScope($filters, 'roadmap');
90
91 $this->validateRoadmapDateRange($scope);
92
93 return $scope;
94 }
95
96 /**
97 * Resolves an accessible board scope without allowing an invalid requested
98 * board to widen back to every board of the same type.
99 */
100 private function resolveBoardScope(array $filters, $boardType)
101 {
102 $boardId = !empty($filters['board_id']) ? (int) $filters['board_id'] : null;
103 $allowedIds = PermissionManager::getBoardIdsForUser(get_current_user_id(), $boardId);
104
105 // getBoardIdsForUser() only honours $boardId for admins; for everyone
106 // else it returns every board they belong to. Narrowing here is what
107 // keeps a board_id the user cannot see from widening the report to all
108 // of their boards.
109 if ($boardId) {
110 $allowedIds = in_array($boardId, array_map('intval', $allowedIds), true)
111 ? [$boardId]
112 : [];
113 }
114
115 $boardIds = [];
116 if ($allowedIds) {
117 $boardIds = Board::whereIn('id', $allowedIds)
118 ->where('type', $boardType)
119 ->whereNull('archived_at')
120 ->excludeTemplates()
121 ->pluck('id')
122 ->toArray();
123 }
124
125 return [
126 'boardIds' => array_map('intval', $boardIds),
127 'start' => $this->startOfDay($this->sanitizeDate($filters['start_date'] ?? null), -6),
128 'end' => $this->endOfDay($this->sanitizeDate($filters['end_date'] ?? null)),
129 ];
130 }
131
132 /**
133 * Reports → Overview.
134 */
135 public function getOverviewReport(array $scope)
136 {
137 if (!$scope['boardIds']) {
138 return $this->emptyOverview();
139 }
140
141 $completed = (int) $this->rangeTasks($scope)
142 ->where('status', 'closed')
143 ->whereBetween('last_completed_at', [$scope['start'], $scope['end']])
144 ->count();
145
146 $total = (int) $this->rangeTasks($scope)->count();
147 $open = (int) $this->rangeTasks($scope)->where('status', 'open')->count();
148 $overdue = (int) $this->boardTasks($scope)->overdue()->count();
149
150 // A task lands in the range because it was touched, so a range can hold
151 // tasks that were already finished before it started. Those are their
152 // own slice rather than being dropped, which is what makes the three
153 // slices sum to the Total tile.
154 $closedEarlier = max(0, $total - $completed - $open);
155
156 return [
157 'stats' => [
158 'total' => $total,
159 'completed' => $completed,
160 'overdue' => $overdue,
161 'activeMembers' => $this->countActiveMembers($scope),
162 'estimatedTime' => $this->formatMinutes($this->sumEstimatedMinutes($scope)),
163 ],
164 'tasksByBoard' => $this->getTasksByBoard($scope),
165 'priority' => $this->getPriorityDistribution($scope),
166 'completion' => [
167 ['key' => 'completed', 'label' => 'Completed', 'value' => $completed],
168 ['key' => 'incomplete', 'label' => 'Still Open', 'value' => $open],
169 ['key' => 'completed_earlier', 'label' => 'Completed Earlier', 'value' => $closedEarlier],
170 ],
171 'dueDate' => $this->getDueDateDistribution($scope),
172 'assigneeWorkload' => $this->getAssigneeWorkload($scope),
173 ];
174 }
175
176 /**
177 * Reports → Tasks.
178 */
179 public function getTasksReport(array $scope)
180 {
181 if (!$scope['boardIds']) {
182 return $this->emptyTasks();
183 }
184
185 return [
186 'byStage' => $this->getTasksByStage($scope),
187 'byAssignee' => $this->getTasksByAssignee($scope),
188 'byLabel' => $this->getTasksByLabel($scope),
189 'priority' => $this->getPriorityDistribution($scope),
190 'recentlyCompleted' => $this->getRecentlyCompleted($scope),
191 ];
192 }
193
194 /**
195 * Reports → Activity.
196 *
197 * The tiles and the by-type rows are two readings of one grouped query, so
198 * they can never disagree.
199 */
200 public function getActivityReport(array $scope)
201 {
202 if (!$scope['boardIds']) {
203 return $this->emptyActivity();
204 }
205
206 $counts = $this->countActivitiesByType($scope);
207
208 $byType = [];
209 foreach (self::ACTIVITY_TYPES as $key => $type) {
210 $byType[] = [
211 'key' => $key,
212 'label' => $type['label'],
213 'icon' => $type['icon'],
214 'value' => $counts[$key] ?? 0,
215 ];
216 }
217
218 return [
219 'stats' => [
220 'tasksCreated' => $counts['task_created'] ?? 0,
221 'stageChanged' => $counts['task_stage_updated'] ?? 0,
222 'commentsAdded' => $counts['comment_created'] ?? 0,
223 'subtasksCreated' => $counts['subtask_added'] ?? 0,
224 'attachmentsAdded' => $counts['task_attachment_added'] ?? 0,
225 ],
226 'byUser' => $this->getActivityByUser($scope),
227 'byType' => $byType,
228 'recent' => $this->getRecentActivities($scope),
229 ];
230 }
231
232 /**
233 * Reports → Roadmap.
234 */
235 public function getRoadmapReport(array $scope)
236 {
237 if (!$scope['boardIds']) {
238 return $this->emptyRoadmap();
239 }
240
241 return [
242 'stats' => $this->getRoadmapStats($scope),
243 'submissions' => $this->getRoadmapSubmissions($scope),
244 'byStage' => $this->getRoadmapIdeasByStage($scope),
245 'popularIdeas' => $this->getPopularRoadmapIdeas($scope),
246 'bySource' => $this->getRoadmapIdeasBySource($scope),
247 ];
248 }
249
250 /*
251 |--------------------------------------------------------------------------
252 | Roadmap pieces
253 |--------------------------------------------------------------------------
254 */
255
256 private function getRoadmapStats(array $scope)
257 {
258 return [
259 'totalIdeas' => (int) $this->roadmapIdeas($scope)->count(),
260 'submittedIdeas' => (int) $this->rangeRoadmapIdeas($scope)->count(),
261 'publicIdeas' => (int) $this->rangeRoadmapIdeas($scope)
262 ->where('source', 'page')
263 ->count(),
264 'completedIdeas' => (int) $this->roadmapIdeas($scope)
265 ->whereBetween('last_completed_at', [$scope['start'], $scope['end']])
266 ->count(),
267 ];
268 }
269
270 /**
271 * Daily submissions include zero-value days so the line never skips dates.
272 */
273 private function getRoadmapSubmissions(array $scope)
274 {
275 $rows = $this->rangeRoadmapIdeas($scope)
276 ->selectRaw('DATE(created_at) as report_date, COUNT(*) as total')
277 ->groupBy('report_date')
278 ->orderBy('report_date', 'ASC')
279 ->get();
280
281 $counts = [];
282 foreach ($rows as $row) {
283 $counts[$row->report_date] = (int) $row->total;
284 }
285
286 $start = new \DateTime(substr($scope['start'], 0, 10));
287 $end = new \DateTime(substr($scope['end'], 0, 10));
288 $end->modify('+1 day');
289
290 $submissions = [];
291 for ($date = $start; $date < $end; $date->modify('+1 day')) {
292 $dateKey = $date->format('Y-m-d');
293 $submissions[] = [
294 'date' => $dateKey,
295 'value' => $counts[$dateKey] ?? 0,
296 ];
297 }
298
299 return $submissions;
300 }
301
302 /**
303 * Same-named stages are one row in the all-roadmaps view.
304 */
305 private function getRoadmapIdeasByStage(array $scope)
306 {
307 $rows = $this->roadmapIdeas($scope)
308 ->selectRaw('stage_id, COUNT(*) as total')
309 ->groupBy('stage_id')
310 ->get();
311
312 $counts = [];
313 foreach ($rows as $row) {
314 $counts[(int) $row->stage_id] = (int) $row->total;
315 }
316
317 if (!$counts) {
318 return [];
319 }
320
321 $stages = BoardTerm::whereIn('id', array_keys($counts))
322 ->where('type', 'stage')
323 ->whereNull('archived_at')
324 ->orderBy('position', 'ASC')
325 ->get();
326
327 $merged = [];
328 foreach ($stages as $stage) {
329 $key = strtolower($stage->title);
330
331 if (!isset($merged[$key])) {
332 $merged[$key] = [
333 'label' => $stage->title,
334 'value' => 0,
335 'position' => (float) $stage->position,
336 ];
337 }
338
339 $merged[$key]['value'] += $counts[(int) $stage->id] ?? 0;
340 $merged[$key]['position'] = min(
341 $merged[$key]['position'],
342 (float) $stage->position
343 );
344 }
345
346 $items = array_values($merged);
347 usort($items, function ($first, $second) {
348 if ($first['position'] === $second['position']) {
349 return strcasecmp($first['label'], $second['label']);
350 }
351
352 return $first['position'] <=> $second['position'];
353 });
354
355 return array_map(function ($item) {
356 unset($item['position']);
357 return $item;
358 }, $items);
359 }
360
361 private function getPopularRoadmapIdeas(array $scope)
362 {
363 $voteSelect = (new TaskService())->getIdeaVoteStatisticsSelect();
364
365 $rows = $this->rangeRoadmapIdeas($scope)
366 ->select(
367 'fbs_tasks.id',
368 'fbs_tasks.slug',
369 'fbs_tasks.title',
370 'fbs_tasks.board_id'
371 )
372 ->selectRaw(
373 "{$voteSelect} as upvotes,"
374 . ' COALESCE(comments_count, 0) as comments,'
375 . " ({$voteSelect} + COALESCE(comments_count, 0)) as popularity"
376 )
377 ->orderBy('popularity', 'DESC')
378 ->orderBy('upvotes', 'DESC')
379 ->orderBy('fbs_tasks.id', 'DESC')
380 ->limit(10)
381 ->get();
382
383 $boardIds = [];
384 foreach ($rows as $row) {
385 $boardIds[] = (int) $row->board_id;
386 }
387
388 $boardTitles = $boardIds
389 ? Board::whereIn('id', array_unique($boardIds))->pluck('title', 'id')->toArray()
390 : [];
391
392 $ideas = [];
393 foreach ($rows as $row) {
394 $boardId = (int) $row->board_id;
395 $ideas[] = [
396 'id' => (int) $row->id,
397 'slug' => $row->slug,
398 'title' => $row->title,
399 'boardId' => $boardId,
400 'board' => $boardTitles[$boardId] ?? __('Untitled', 'fluent-boards'),
401 'upvotes' => (int) $row->upvotes,
402 'comments' => (int) $row->comments,
403 'popularity' => (int) $row->popularity,
404 ];
405 }
406
407 return $ideas;
408 }
409
410 private function getRoadmapIdeasBySource(array $scope)
411 {
412 $rows = $this->rangeRoadmapIdeas($scope)
413 ->selectRaw(
414 "CASE"
415 . " WHEN source = 'page' THEN 'page'"
416 . " WHEN source IS NULL OR source = '' OR source = 'web' THEN 'web'"
417 . " ELSE 'other' END as source_key,"
418 . ' COUNT(*) as total'
419 )
420 ->groupBy('source_key')
421 ->get();
422
423 $counts = ['page' => 0, 'web' => 0, 'other' => 0];
424 foreach ($rows as $row) {
425 $key = isset($counts[$row->source_key]) ? $row->source_key : 'other';
426 $counts[$key] += (int) $row->total;
427 }
428
429 return [
430 ['key' => 'page', 'label' => 'Public Page', 'value' => $counts['page'], 'colorKey' => 'primary'],
431 ['key' => 'web', 'label' => 'Admin / Web', 'value' => $counts['web'], 'colorKey' => 'success'],
432 ['key' => 'other', 'label' => 'Other', 'value' => $counts['other'], 'colorKey' => 'neutral'],
433 ];
434 }
435
436 /*
437 |--------------------------------------------------------------------------
438 | Overview pieces
439 |--------------------------------------------------------------------------
440 */
441
442 private function getTasksByBoard(array $scope)
443 {
444 $rows = $this->rangeTasks($scope)
445 ->selectRaw('board_id, COUNT(*) as total')
446 ->groupBy('board_id')
447 ->orderBy('total', 'DESC')
448 ->limit(self::TOP_ROWS)
449 ->get();
450
451 $boardIds = [];
452 foreach ($rows as $row) {
453 $boardIds[] = (int) $row->board_id;
454 }
455
456 $titles = $boardIds
457 ? Board::whereIn('id', $boardIds)->pluck('title', 'id')->toArray()
458 : [];
459
460 $boards = [];
461 foreach ($rows as $row) {
462 $id = (int) $row->board_id;
463 $boards[] = [
464 'id' => $id,
465 'title' => $titles[$id] ?? __('Untitled', 'fluent-boards'),
466 'total' => (int) $row->total,
467 ];
468 }
469
470 return $boards;
471 }
472
473 private function getPriorityDistribution(array $scope)
474 {
475 $rows = $this->rangeTasks($scope)
476 ->selectRaw('priority, COUNT(*) as total')
477 ->groupBy('priority')
478 ->get();
479
480 return $this->formatPriorityRows($rows);
481 }
482
483 /**
484 * Normalizes grouped priority rows into the fixed widget bucket order.
485 */
486 private function formatPriorityRows($rows)
487 {
488 $counts = array_fill_keys(array_keys(self::PRIORITY_BUCKETS), 0);
489 foreach ($rows as $row) {
490 $key = $row->priority ? strtolower($row->priority) : 'none';
491 if (!isset($counts[$key])) {
492 $key = 'none';
493 }
494 $counts[$key] += (int) $row->total;
495 }
496
497 $items = [];
498 foreach (self::PRIORITY_BUCKETS as $key => $label) {
499 $items[] = ['key' => $key, 'label' => $label, 'value' => $counts[$key]];
500 }
501
502 return [
503 'total' => array_sum($counts),
504 'items' => $items,
505 ];
506 }
507
508 /**
509 * Due-date buckets over the open tasks in range. One grouped pass rather
510 * than five counts, so the buckets always sum to the same population.
511 */
512 private function getDueDateDistribution(array $scope)
513 {
514 $todayStart = $this->sqlDate(gmdate('Y-m-d 00:00:00', current_time('timestamp')));
515 $todayEnd = $this->sqlDate(gmdate('Y-m-d 23:59:59', current_time('timestamp')));
516 $weekEnd = $this->sqlDate(gmdate('Y-m-d 23:59:59', current_time('timestamp') + (6 * DAY_IN_SECONDS)));
517
518 $row = $this->rangeTasks($scope)
519 ->where('status', 'open')
520 ->selectRaw(
521 "SUM(CASE WHEN due_at IS NULL THEN 1 ELSE 0 END) as no_due_date,"
522 . " SUM(CASE WHEN due_at IS NOT NULL AND due_at < '{$todayStart}' THEN 1 ELSE 0 END) as overdue,"
523 . " SUM(CASE WHEN due_at >= '{$todayStart}' AND due_at <= '{$todayEnd}' THEN 1 ELSE 0 END) as due_today,"
524 . " SUM(CASE WHEN due_at > '{$todayEnd}' AND due_at <= '{$weekEnd}' THEN 1 ELSE 0 END) as next_seven,"
525 . " SUM(CASE WHEN due_at > '{$weekEnd}' THEN 1 ELSE 0 END) as later"
526 )
527 ->first();
528
529 return [
530 ['key' => 'overdue', 'label' => 'Overdue', 'value' => $row ? (int) $row->overdue : 0],
531 ['key' => 'today', 'label' => 'Today', 'value' => $row ? (int) $row->due_today : 0],
532 ['key' => 'next7days', 'label' => 'Next 7 Days', 'value' => $row ? (int) $row->next_seven : 0],
533 ['key' => 'later', 'label' => 'Later', 'value' => $row ? (int) $row->later : 0],
534 ['key' => 'no_due_date', 'label' => 'No Due Date', 'value' => $row ? (int) $row->no_due_date : 0],
535 ];
536 }
537
538 /**
539 * Per-assignee open / completed / overdue counts.
540 *
541 * `workload` is the member's open count as a share of the busiest member's,
542 * so the bar reads as "who is carrying the most" rather than an absolute
543 * capacity the plugin has no way of knowing.
544 */
545 private function getAssigneeWorkload(array $scope)
546 {
547 $now = $this->sqlDate(current_time('mysql'));
548
549 $rows = $this->assigneeRelationQuery($scope)
550 ->selectRaw(
551 'rel.foreign_id as user_id, COUNT(*) as total,'
552 . " SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as completed,"
553 . " SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_count,"
554 . " SUM(CASE WHEN status = 'open' AND last_completed_at IS NULL"
555 . " AND due_at IS NOT NULL AND due_at <= '{$now}' THEN 1 ELSE 0 END) as overdue"
556 )
557 ->groupBy('rel.foreign_id')
558 ->orderBy('open_count', 'DESC')
559 ->limit(self::TOP_ROWS)
560 ->get();
561
562 $userIds = [];
563 foreach ($rows as $row) {
564 $userIds[] = (int) $row->user_id;
565 }
566
567 $estimations = $this->sumEstimatedMinutesByAssignee($scope, $userIds);
568 $users = $this->getUsers($userIds);
569
570 $busiest = 0;
571 foreach ($rows as $row) {
572 $busiest = max($busiest, (int) $row->open_count);
573 }
574
575 $members = [];
576 foreach ($rows as $row) {
577 $userId = (int) $row->user_id;
578 $user = $users[$userId] ?? null;
579 $open = (int) $row->open_count;
580
581 $members[] = [
582 'id' => $userId,
583 'name' => $user ? $user['name'] : __('Unknown', 'fluent-boards'),
584 'avatar' => $user ? $user['avatar'] : '',
585 'open' => $open,
586 'completed' => (int) $row->completed,
587 'overdue' => (int) $row->overdue,
588 'estimated' => $this->formatMinutes($estimations[$userId] ?? 0),
589 'workload' => $busiest ? (int) round(($open / $busiest) * 100) : 0,
590 ];
591 }
592
593 return $members;
594 }
595
596 private function countActiveMembers(array $scope)
597 {
598 $rows = $this->rangeActivities($scope)
599 ->selectRaw('COUNT(DISTINCT created_by) as total')
600 ->first();
601
602 return $rows ? (int) $rows->total : 0;
603 }
604
605 /*
606 |--------------------------------------------------------------------------
607 | Tasks pieces
608 |--------------------------------------------------------------------------
609 */
610
611 private function getTasksByStage(array $scope)
612 {
613 $rows = $this->rangeTasks($scope)
614 ->selectRaw('stage_id, COUNT(*) as total')
615 ->groupBy('stage_id')
616 ->orderBy('total', 'DESC')
617 ->get();
618
619 $stageIds = [];
620 foreach ($rows as $row) {
621 $stageIds[] = (int) $row->stage_id;
622 }
623
624 $titles = $stageIds
625 ? BoardTerm::whereIn('id', $stageIds)->pluck('title', 'id')->toArray()
626 : [];
627
628 // Stages are per-board, so "Done" would otherwise get one row per board
629 // in an all-boards report; same-named stages are counted together.
630 $merged = [];
631 foreach ($rows as $row) {
632 $id = (int) $row->stage_id;
633 $title = $titles[$id] ?? __('No Stage', 'fluent-boards');
634 $key = strtolower($title);
635
636 if (!isset($merged[$key])) {
637 $merged[$key] = ['label' => $title, 'value' => 0];
638 }
639
640 $merged[$key]['value'] += (int) $row->total;
641 }
642
643 $stages = array_values($merged);
644
645 usort($stages, function ($first, $second) {
646 return $second['value'] - $first['value'];
647 });
648
649 return array_slice($stages, 0, self::TOP_ROWS);
650 }
651
652 private function getTasksByAssignee(array $scope)
653 {
654 $rows = $this->assigneeRelationQuery($scope)
655 ->selectRaw(
656 // Single quotes only: the connection rewrites " to a backtick
657 // before the query reaches wpdb, which would turn a string
658 // literal into an identifier.
659 'rel.foreign_id as user_id, COUNT(*) as total,'
660 . " SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as completed"
661 )
662 ->groupBy('rel.foreign_id')
663 ->orderBy('total', 'DESC')
664 ->limit(self::TOP_ROWS)
665 ->get();
666
667 $userIds = [];
668 foreach ($rows as $row) {
669 $userIds[] = (int) $row->user_id;
670 }
671
672 $users = $this->getUsers($userIds);
673
674 $assignees = [];
675 foreach ($rows as $row) {
676 $userId = (int) $row->user_id;
677 $user = $users[$userId] ?? null;
678
679 $assignees[] = [
680 'id' => $userId,
681 'name' => $user ? $user['name'] : __('Unknown', 'fluent-boards'),
682 'avatar' => $user ? $user['avatar'] : '',
683 'completed' => (int) $row->completed,
684 'total' => (int) $row->total,
685 ];
686 }
687
688 return $assignees;
689 }
690
691 /**
692 * Labels keep the colour stored on `fbs_board_terms` so the chart can honour
693 * it without a lookup table on the client.
694 *
695 * Labels belong to a board, so an all-boards report would otherwise show
696 * "Bug" once per board. Rows are merged by title — the first (largest) row
697 * of a title decides the colour, since sibling boards nearly always use the
698 * same palette entry for the same name.
699 */
700 private function getTasksByLabel(array $scope)
701 {
702 $rows = $this->relationQuery($scope, Constant::OBJECT_TYPE_TASK_LABEL)
703 ->selectRaw('rel.foreign_id as label_id, COUNT(*) as total')
704 ->groupBy('rel.foreign_id')
705 ->orderBy('total', 'DESC')
706 ->get();
707
708 $labelIds = [];
709 foreach ($rows as $row) {
710 $labelIds[] = (int) $row->label_id;
711 }
712
713 $labels = $labelIds
714 ? BoardTerm::whereIn('id', $labelIds)->get()->keyBy('id')
715 : [];
716
717 $merged = [];
718 foreach ($rows as $row) {
719 $id = (int) $row->label_id;
720 $label = $labels[$id] ?? null;
721
722 if (!$label || !$label->title) {
723 continue;
724 }
725
726 $key = strtolower($label->title);
727
728 if (!isset($merged[$key])) {
729 $merged[$key] = [
730 'id' => $id,
731 'title' => $label->title,
732 'total' => 0,
733 // Labels render as their fill colour everywhere else in the UI.
734 'color' => $label->bg_color ?: $label->color,
735 ];
736 }
737
738 $merged[$key]['total'] += (int) $row->total;
739 }
740
741 $items = array_values($merged);
742
743 usort($items, function ($first, $second) {
744 return $second['total'] - $first['total'];
745 });
746
747 return array_slice($items, 0, self::TOP_ROWS);
748 }
749
750 private function getRecentlyCompleted(array $scope)
751 {
752 $tasks = $this->boardTasks($scope)
753 ->where('status', 'closed')
754 ->whereBetween('last_completed_at', [$scope['start'], $scope['end']])
755 ->with(['board', 'stage', 'assignees'])
756 ->orderBy('last_completed_at', 'DESC')
757 ->limit(10)
758 ->get();
759
760 $taskIds = [];
761 foreach ($tasks as $task) {
762 $taskIds[] = (int) $task->id;
763 }
764
765 $estimations = $this->getEstimatedMinutesByTask($taskIds);
766
767 $items = [];
768 foreach ($tasks as $task) {
769 $assignee = $task->assignees ? $task->assignees->first() : null;
770 $minutes = $estimations[(int) $task->id] ?? 0;
771
772 $items[] = [
773 'id' => (int) $task->id,
774 'title' => $task->title,
775 // boardId + slug are what the task route needs to open the task.
776 'slug' => $task->slug,
777 'boardId' => (int) $task->board_id,
778 'board' => $task->board ? $task->board->title : '',
779 'boardColor' => $this->getBoardColor($task->board),
780 'stage' => $task->stage ? $task->stage->title : '',
781 'priority' => $task->priority ?: 'none',
782 'assignee' => $assignee ? $assignee->display_name : '',
783 'assigneeAvatar' => $assignee ? fluent_boards_user_avatar($assignee->user_email) : '',
784 'estimated' => $minutes ? $this->formatMinutes($minutes) : '',
785 ];
786 }
787
788 return $items;
789 }
790
791 /*
792 |--------------------------------------------------------------------------
793 | Activity pieces
794 |--------------------------------------------------------------------------
795 */
796
797 /**
798 * One grouped pass over `fbs_activities`, folded into the report's type keys.
799 */
800 private function countActivitiesByType(array $scope)
801 {
802 $rows = $this->rangeActivities($scope)
803 ->selectRaw('`column` as activity_column, action, COUNT(*) as total')
804 ->groupBy('activity_column', 'action')
805 ->get();
806
807 $counts = [];
808 foreach ($rows as $row) {
809 $key = $this->matchActivityType($row->activity_column, $row->action);
810
811 if (!$key) {
812 continue;
813 }
814
815 $counts[$key] = ($counts[$key] ?? 0) + (int) $row->total;
816 }
817
818 return $counts;
819 }
820
821 private function matchActivityType($column, $action)
822 {
823 foreach (self::ACTIVITY_TYPES as $key => $type) {
824 if (strcasecmp((string) $column, $type['column']) !== 0) {
825 continue;
826 }
827
828 if ($type['actions'] === null || in_array($action, $type['actions'], true)) {
829 return $key;
830 }
831 }
832
833 return null;
834 }
835
836 private function getActivityByUser(array $scope)
837 {
838 $rows = $this->rangeActivities($scope)
839 ->selectRaw('created_by, COUNT(*) as total')
840 ->groupBy('created_by')
841 ->orderBy('total', 'DESC')
842 ->limit(self::TOP_ROWS)
843 ->get();
844
845 $userIds = [];
846 foreach ($rows as $row) {
847 $userIds[] = (int) $row->created_by;
848 }
849
850 $users = $this->getUsers($userIds);
851
852 $items = [];
853 foreach ($rows as $row) {
854 $userId = (int) $row->created_by;
855 $user = $users[$userId] ?? null;
856
857 $items[] = [
858 'id' => $userId,
859 'name' => $user ? $user['name'] : __('Unknown', 'fluent-boards'),
860 'avatar' => $user ? $user['avatar'] : '',
861 'count' => (int) $row->total,
862 ];
863 }
864
865 return $items;
866 }
867
868 private function getRecentActivities(array $scope)
869 {
870 $activities = Activity::query()
871 ->where('fbs_activities.object_type', Constant::ACTIVITY_TASK)
872 ->whereBetween('fbs_activities.created_at', [$scope['start'], $scope['end']])
873 ->whereIn('fbs_activities.object_id', $this->scopedTaskIdQuery($scope))
874 ->with(['user', 'task'])
875 ->orderBy('fbs_activities.created_at', 'DESC')
876 ->limit(10)
877 ->get();
878
879 $items = [];
880 foreach ($activities as $activity) {
881 $items[] = [
882 'id' => (int) $activity->id,
883 'taskId' => (int) $activity->object_id,
884 'taskSlug' => $activity->task ? $activity->task->slug : '',
885 'boardId' => $activity->task ? (int) $activity->task->board_id : 0,
886 'actor' => $activity->user ? $activity->user->display_name : __('Someone', 'fluent-boards'),
887 'action' => $this->describeActivity($activity),
888 'subject' => $activity->task ? $activity->task->title : '',
889 'date' => $this->formatDate($activity->created_at),
890 ];
891 }
892
893 return $items;
894 }
895
896 /**
897 * Turns an activity's action + column into the verb phrase the feed shows.
898 */
899 private function describeActivity($activity)
900 {
901 $column = strtolower((string) $activity->column);
902 $action = strtolower((string) $activity->action);
903
904 if ($column === 'stage' && $action === 'changed') {
905 return __('moved', 'fluent-boards');
906 }
907
908 if ($column === 'comment' && $action === 'added') {
909 return __('commented on', 'fluent-boards');
910 }
911
912 if ($column === 'attachment' && $action === 'added') {
913 return __('attached a file to', 'fluent-boards');
914 }
915
916 if ($column === 'subtask' && in_array($action, ['added', 'cloned'], true)) {
917 return __('added a subtask to', 'fluent-boards');
918 }
919
920 if ($column === 'label') {
921 return $action === 'removed'
922 ? __('removed a label from', 'fluent-boards')
923 : __('labelled', 'fluent-boards');
924 }
925
926 if ($column === 'due date') {
927 return __('set a due date on', 'fluent-boards');
928 }
929
930 if ($column === 'task' && $action === 'created') {
931 return __('created', 'fluent-boards');
932 }
933
934 if ($column === 'task' && $action === 'closed') {
935 return __('completed', 'fluent-boards');
936 }
937
938 /* translators: 1: action verb, 2: field name */
939 return trim(sprintf('%1$s %2$s', $action, $column));
940 }
941
942 /*
943 |--------------------------------------------------------------------------
944 | Query building
945 |--------------------------------------------------------------------------
946 */
947
948 /**
949 * Every top-level, non-archived task on the scoped boards.
950 */
951 private function boardTasks(array $scope)
952 {
953 // Columns stay table-qualified so the same builders can be joined
954 // against `fbs_relations`, which carries its own created_at/updated_at.
955 return Task::query()
956 ->whereNull('fbs_tasks.parent_id')
957 ->whereNull('fbs_tasks.archived_at')
958 ->whereIn('fbs_tasks.board_id', $scope['boardIds']);
959 }
960
961 /**
962 * Current top-level ideas in the roadmap scope.
963 */
964 private function roadmapIdeas(array $scope)
965 {
966 return $this->boardTasks($scope)->where('fbs_tasks.type', 'roadmap');
967 }
968
969 /**
970 * Ideas submitted inside the reporting window.
971 */
972 private function rangeRoadmapIdeas(array $scope)
973 {
974 return $this->roadmapIdeas($scope)
975 ->whereBetween('fbs_tasks.created_at', [$scope['start'], $scope['end']]);
976 }
977
978 /**
979 * The scoped tasks touched inside the reporting window.
980 */
981 private function rangeTasks(array $scope)
982 {
983 return $this->boardTasks($scope)->where(function ($query) use ($scope) {
984 $query->whereBetween('fbs_tasks.created_at', [$scope['start'], $scope['end']])
985 ->orWhereBetween('fbs_tasks.last_completed_at', [$scope['start'], $scope['end']])
986 ->orWhereBetween('fbs_tasks.updated_at', [$scope['start'], $scope['end']]);
987 });
988 }
989
990 /**
991 * Task activities in range, scoped to the boards through their task.
992 *
993 * `fbs_activities` carries no board_id, so the board filter is a subquery on
994 * task ids rather than a join — that keeps the grouped aggregates on a single
995 * table and their raw column names unambiguous.
996 */
997 private function rangeActivities(array $scope)
998 {
999 return Activity::query()
1000 ->where('object_type', Constant::ACTIVITY_TASK)
1001 ->whereBetween('created_at', [$scope['start'], $scope['end']])
1002 ->whereIn('object_id', $this->scopedTaskIdQuery($scope));
1003 }
1004
1005 /**
1006 * Sub-select of every task id on the scoped boards (subtasks included, since
1007 * activity is logged against them too).
1008 */
1009 private function scopedTaskIdQuery(array $scope)
1010 {
1011 return Task::query()
1012 ->select('id')
1013 ->whereNull('archived_at')
1014 ->whereIn('board_id', $scope['boardIds']);
1015 }
1016
1017 /**
1018 * In-range tasks joined to a `fbs_relations` row, ready to be grouped by the
1019 * related id — assignees by default, labels when asked.
1020 *
1021 * The join target is a DISTINCT sub-query rather than the table itself:
1022 * `fbs_relations` has no unique index on (object_id, foreign_id,
1023 * object_type) — only plain keys — so a task can legitimately carry the same
1024 * assignee or label twice. Joining the raw table fans those duplicates out
1025 * and multiplies every aggregate built on top: task counts, completed
1026 * counts, workload and the estimate sums alike. Deduplicating once here
1027 * fixes all of them, and keeps the callers free of DISTINCT bookkeeping.
1028 *
1029 * Tasks stay the base table and the relation is aliased, because raw SELECT
1030 * expressions are not table-prefixed by the query builder: only aliases and
1031 * unqualified column names are safe inside them.
1032 */
1033 private function relationQuery(array $scope, $objectType)
1034 {
1035 $distinctRelations = Relation::query()
1036 ->distinct()
1037 ->select('object_id', 'foreign_id')
1038 ->where('object_type', $objectType);
1039
1040 return $this->rangeTasks($scope)
1041 ->joinSub($distinctRelations, 'rel', function ($join) {
1042 $join->on('rel.object_id', '=', 'fbs_tasks.id');
1043 });
1044 }
1045
1046 /**
1047 * Assignee relations joined to their in-range task, ready to be grouped.
1048 */
1049 private function assigneeRelationQuery(array $scope)
1050 {
1051 return $this->relationQuery($scope, Constant::OBJECT_TYPE_TASK_ASSIGNEE);
1052 }
1053
1054 /*
1055 |--------------------------------------------------------------------------
1056 | Estimations
1057 |--------------------------------------------------------------------------
1058 */
1059
1060 /**
1061 * The `fbs_task_metas` rows holding the one estimate that counts per task.
1062 *
1063 * Nothing in the schema stops a task from carrying more than one
1064 * `_estimated_minutes` row — Pro's writer does a check-then-create, so a
1065 * race or historical data can leave duplicates. Summing them all would both
1066 * inflate every estimate here and disagree with the task modal, which reads
1067 * the estimate with `first()`. Every aggregation below therefore counts only
1068 * the lowest-id row per task, which is the row Pro displays.
1069 */
1070 private function firstEstimateRowIds()
1071 {
1072 return TaskMeta::query()
1073 ->selectRaw('MIN(id) as id')
1074 ->where('key', '_estimated_minutes')
1075 ->groupBy('task_id');
1076 }
1077
1078 /**
1079 * `_estimated_minutes` is written by Pro's time-tracking module but lives in
1080 * the free `fbs_task_metas` table, so reports can read it without Pro; sites
1081 * that never set an estimate simply report zero.
1082 */
1083 private function sumEstimatedMinutes(array $scope)
1084 {
1085 $row = TaskMeta::query()
1086 ->selectRaw('SUM(CAST(value AS UNSIGNED)) as total')
1087 ->whereIn('id', $this->firstEstimateRowIds())
1088 ->whereIn('task_id', $this->rangeTasks($scope)->select('id'))
1089 ->first();
1090
1091 return $row ? (int) $row->total : 0;
1092 }
1093
1094 private function sumEstimatedMinutesByAssignee(array $scope, array $userIds)
1095 {
1096 if (!$userIds) {
1097 return [];
1098 }
1099
1100 $rows = $this->assigneeRelationQuery($scope)
1101 ->selectRaw('rel.foreign_id as user_id, SUM(CAST(meta.value AS UNSIGNED)) as total')
1102 ->join((new TaskMeta())->getTable() . ' as meta', function ($join) {
1103 $join->on('meta.task_id', '=', 'fbs_tasks.id')
1104 ->where('meta.key', '_estimated_minutes');
1105 })
1106 // Without this the join fans out over duplicate meta rows and
1107 // multiplies the member's estimate.
1108 ->whereIn('meta.id', $this->firstEstimateRowIds())
1109 ->whereIn('rel.foreign_id', $userIds)
1110 ->groupBy('rel.foreign_id')
1111 ->get();
1112
1113 $totals = [];
1114 foreach ($rows as $row) {
1115 $totals[(int) $row->user_id] = (int) $row->total;
1116 }
1117
1118 return $totals;
1119 }
1120
1121 private function getEstimatedMinutesByTask(array $taskIds)
1122 {
1123 if (!$taskIds) {
1124 return [];
1125 }
1126
1127 $rows = TaskMeta::query()
1128 ->selectRaw('task_id, CAST(value AS UNSIGNED) as total')
1129 ->whereIn('id', $this->firstEstimateRowIds())
1130 ->whereIn('task_id', $taskIds)
1131 ->get();
1132
1133 $totals = [];
1134 foreach ($rows as $row) {
1135 $totals[(int) $row->task_id] = (int) $row->total;
1136 }
1137
1138 return $totals;
1139 }
1140
1141 /*
1142 |--------------------------------------------------------------------------
1143 | Helpers
1144 |--------------------------------------------------------------------------
1145 */
1146
1147 /**
1148 * A board's background is a serialized array that may hold a colour or an
1149 * uploaded image; only the colour is useful as a row swatch.
1150 */
1151 private function getBoardColor($board)
1152 {
1153 if (!$board) {
1154 return '';
1155 }
1156
1157 $background = $board->background;
1158
1159 if (is_array($background) && !empty($background['color'])) {
1160 return $background['color'];
1161 }
1162
1163 return '';
1164 }
1165
1166 private function getUsers(array $userIds)
1167 {
1168 $userIds = array_values(array_unique(array_filter(array_map('intval', $userIds))));
1169
1170 if (!$userIds) {
1171 return [];
1172 }
1173
1174 $users = [];
1175 foreach (User::whereIn('ID', $userIds)->get() as $user) {
1176 $users[(int) $user->ID] = [
1177 'name' => $user->display_name,
1178 'avatar' => fluent_boards_user_avatar($user->user_email),
1179 ];
1180 }
1181
1182 return $users;
1183 }
1184
1185 /**
1186 * Minutes as the compact "12h 30m" the report tables show.
1187 */
1188 private function formatMinutes($minutes)
1189 {
1190 $minutes = (int) $minutes;
1191
1192 if ($minutes <= 0) {
1193 return '0m';
1194 }
1195
1196 $hours = intdiv($minutes, 60);
1197 $rest = $minutes % 60;
1198
1199 if (!$hours) {
1200 return $rest . 'm';
1201 }
1202
1203 return $rest ? $hours . 'h ' . $rest . 'm' : $hours . 'h';
1204 }
1205
1206 /**
1207 * Escapes a server-generated timestamp for inlining into a raw expression.
1208 *
1209 * Raw SELECTs here need literal dates (the builder does not bind inside
1210 * them); every value passed in is produced by current_time(), never by a
1211 * request, and is escaped regardless.
1212 */
1213 private function sqlDate($date)
1214 {
1215 return esc_sql($date);
1216 }
1217
1218 private function formatDate($date)
1219 {
1220 if (!$date) {
1221 return '';
1222 }
1223
1224 return date_i18n(get_option('date_format') . ', ' . get_option('time_format'), strtotime($date));
1225 }
1226
1227 /**
1228 * Accepts only `Y-m-d`, and only if it is a real calendar date — same guard
1229 * the timesheet report uses.
1230 */
1231 private function sanitizeDate($date)
1232 {
1233 if (!$date || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
1234 return null;
1235 }
1236
1237 list($year, $month, $day) = explode('-', $date);
1238
1239 if (!checkdate((int) $month, (int) $day, (int) $year)) {
1240 return null;
1241 }
1242
1243 return $date;
1244 }
1245
1246 /**
1247 * Rejects inverted or oversized ranges before roadmap queries and daily
1248 * response-point generation begin.
1249 */
1250 private function validateRoadmapDateRange(array $scope)
1251 {
1252 $start = new \DateTimeImmutable(substr($scope['start'], 0, 10));
1253 $end = new \DateTimeImmutable(substr($scope['end'], 0, 10));
1254
1255 if ($start > $end) {
1256 throw new \InvalidArgumentException(
1257 esc_html__('Start date cannot be after end date.', 'fluent-boards')
1258 );
1259 }
1260
1261 $days = (int) $start->diff($end)->days + 1;
1262 if ($days > self::MAX_ROADMAP_REPORT_DAYS) {
1263 throw new \InvalidArgumentException(
1264 sprintf(
1265 esc_html__('Roadmap reports are limited to %d days.', 'fluent-boards'),
1266 self::MAX_ROADMAP_REPORT_DAYS
1267 )
1268 );
1269 }
1270 }
1271
1272 /**
1273 * Ranges are widened to whole days in site-local time, because task and
1274 * activity timestamps are written with current_time('mysql').
1275 */
1276 private function startOfDay($date, $fallbackDayOffset = 0)
1277 {
1278 if (!$date) {
1279 $timestamp = current_time('timestamp') + ($fallbackDayOffset * DAY_IN_SECONDS);
1280 return gmdate('Y-m-d 00:00:00', $timestamp);
1281 }
1282
1283 return $date . ' 00:00:00';
1284 }
1285
1286 private function endOfDay($date)
1287 {
1288 if (!$date) {
1289 return gmdate('Y-m-d 23:59:59', current_time('timestamp'));
1290 }
1291
1292 return $date . ' 23:59:59';
1293 }
1294
1295 private function emptyOverview()
1296 {
1297 return [
1298 'stats' => [
1299 'total' => 0,
1300 'completed' => 0,
1301 'overdue' => 0,
1302 'activeMembers' => 0,
1303 'estimatedTime' => '0m',
1304 ],
1305 'tasksByBoard' => [],
1306 'priority' => $this->emptyPriority(),
1307 'completion' => [
1308 ['key' => 'completed', 'label' => 'Completed', 'value' => 0],
1309 ['key' => 'incomplete', 'label' => 'Still Open', 'value' => 0],
1310 ['key' => 'completed_earlier', 'label' => 'Completed Earlier', 'value' => 0],
1311 ],
1312 'dueDate' => [
1313 ['key' => 'overdue', 'label' => 'Overdue', 'value' => 0],
1314 ['key' => 'today', 'label' => 'Today', 'value' => 0],
1315 ['key' => 'next7days', 'label' => 'Next 7 Days', 'value' => 0],
1316 ['key' => 'later', 'label' => 'Later', 'value' => 0],
1317 ['key' => 'no_due_date', 'label' => 'No Due Date', 'value' => 0],
1318 ],
1319 'assigneeWorkload' => [],
1320 ];
1321 }
1322
1323 private function emptyTasks()
1324 {
1325 return [
1326 'byStage' => [],
1327 'byAssignee' => [],
1328 'byLabel' => [],
1329 'priority' => $this->emptyPriority(),
1330 'recentlyCompleted' => [],
1331 ];
1332 }
1333
1334 private function emptyActivity()
1335 {
1336 $byType = [];
1337 foreach (self::ACTIVITY_TYPES as $key => $type) {
1338 $byType[] = [
1339 'key' => $key,
1340 'label' => $type['label'],
1341 'icon' => $type['icon'],
1342 'value' => 0,
1343 ];
1344 }
1345
1346 return [
1347 'stats' => [
1348 'tasksCreated' => 0,
1349 'stageChanged' => 0,
1350 'commentsAdded' => 0,
1351 'subtasksCreated' => 0,
1352 'attachmentsAdded' => 0,
1353 ],
1354 'byUser' => [],
1355 'byType' => $byType,
1356 'recent' => [],
1357 ];
1358 }
1359
1360 private function emptyRoadmap()
1361 {
1362 return [
1363 'stats' => [
1364 'totalIdeas' => 0,
1365 'submittedIdeas' => 0,
1366 'publicIdeas' => 0,
1367 'completedIdeas' => 0,
1368 ],
1369 'submissions' => [],
1370 'byStage' => [],
1371 'popularIdeas' => [],
1372 'bySource' => [
1373 ['key' => 'page', 'label' => 'Public Page', 'value' => 0, 'colorKey' => 'primary'],
1374 ['key' => 'web', 'label' => 'Admin / Web', 'value' => 0, 'colorKey' => 'success'],
1375 ['key' => 'other', 'label' => 'Other', 'value' => 0, 'colorKey' => 'neutral'],
1376 ],
1377 ];
1378 }
1379
1380 private function emptyPriority()
1381 {
1382 $items = [];
1383 foreach (self::PRIORITY_BUCKETS as $key => $label) {
1384 $items[] = ['key' => $key, 'label' => $label, 'value' => 0];
1385 }
1386
1387 return ['total' => 0, 'items' => $items];
1388 }
1389 }
1390