Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 227
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
TaskListManager
0.00% covered (danger)
0.00%
0 / 227
0.00% covered (danger)
0.00%
0 / 14
3192
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getTodayLists
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
42
 getActiveListWithCarryover
0.00% covered (danger)
0.00%
0 / 68
0.00% covered (danger)
0.00%
0 / 1
272
 getActiveList
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getTasksForList
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 getAllTasksForList
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getScheduleForDay
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getFullSchedule
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 updateSchedule
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 updateScheduleBatch
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 deleteSchedule
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 calculateCompletionPercentage
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 createTaskList
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 invalidateCache
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace BuyerKiosk\Workbook;
4
5use \Store;
6use \PDO;
7use \PDOException;
8
9/**
10 * TaskListManager - Main class for managing task lists with scheduling
11 *
12 * Handles task list retrieval, filtering by day/time, completion tracking,
13 * carryover tasks from previous lists, and cache management for the Workbook dashboard.
14 */
15class TaskListManager
16{
17    /**
18     * @var Store Store object
19     */
20    private $store;
21
22    /**
23     * @var PDO Database connection
24     */
25    private $db;
26
27    /**
28     * @var \Predis\Client Redis client for caching
29     */
30    private $cache;
31
32    /**
33     * Constructor
34     *
35     * @param Store $store Store object with configuration
36     */
37    public function __construct(Store $store)
38    {
39        $this->store = $store;
40        $this->db = dbConnectByName($store->getDbName());
41        $this->cache = new \Predis\Client($_ENV['REDIS_URL']);
42    }
43
44    /**
45     * Get all task lists scheduled for current day with their tasks
46     *
47     * Filters task lists based on scheduledDays field matching current day of week
48     * Includes tasks for each list with completion status
49     *
50     * @return array Array of task list rows with tasks included
51     */
52    public function getTodayLists(): array
53    {
54        $timezone = new \DateTimeZone($this->store->timezone);
55        $now = new \DateTime('now', $timezone);
56        $dayOfWeek = strtoupper($now->format('D')); // SUN, MON, TUE, etc.
57
58        try {
59            $stmt = $this->db->prepare("
60                SELECT dtl.*, tg.groupName,
61                       dls.startTime as scheduledStartTime
62                FROM workbook_task_lists dtl
63                INNER JOIN taskGroups tg ON dtl.groupId = tg.id
64                LEFT JOIN workbook_list_schedule dls ON dtl.id = dls.listId AND dls.dayOfWeek = :dayOfWeek AND dls.isActive = 1
65                WHERE dtl.isActive = 1
66                  AND dtl.scheduledDays LIKE :dayOfWeekPattern
67                ORDER BY COALESCE(dls.startTime, dtl.startTime) ASC, dtl.sortOrder ASC
68            ");
69            $stmt->execute([
70                ':dayOfWeek' => $dayOfWeek,
71                ':dayOfWeekPattern' => "%{$dayOfWeek}%"
72            ]);
73
74            $lists = $stmt->fetchAll(PDO::FETCH_ASSOC);
75
76            // Fetch tasks for each list
77            foreach ($lists as &$list) {
78                // Get tasks due up to current time (for display)
79                $list['tasks'] = $this->getTasksForList((int) $list['groupId'], true);
80                // Get all tasks for the day (for total count)
81                $allTasks = $this->getTasksForList((int) $list['groupId'], false);
82
83                $list['currentCount'] = count($list['tasks']); // Tasks due now
84                $list['totalCount'] = count($allTasks); // All tasks for today
85                $list['completedCount'] = 0;
86                foreach ($allTasks as $task) {
87                    if (isset($task['status']) && $task['status'] == 2) {
88                        $list['completedCount']++;
89                    }
90                }
91                // Percentage based on tasks due so far (not future tasks)
92                $list['completionPercentage'] = $this->calculateCompletionPercentage($list['tasks']);
93                // Use schedule-specific start time if available
94                $list['effectiveStartTime'] = $list['scheduledStartTime'] ?? $list['startTime'];
95            }
96
97            return $lists;
98        } catch (PDOException $e) {
99            error_log("TaskListManager::getTodayLists error: " . $e->getMessage());
100            return [];
101        }
102    }
103
104    /**
105     * Get the currently active task list based on time and day schedule
106     *
107     * Returns the list with the most recent startTime that has already passed,
108     * along with carryover tasks from earlier lists that are not completed.
109     *
110     * @return array Contains 'activeList' and 'carryoverTasks'
111     */
112    public function getActiveListWithCarryover(): array
113    {
114        $timezone = new \DateTimeZone($this->store->timezone);
115        $now = new \DateTime('now', $timezone);
116        $dayOfWeek = strtoupper($now->format('D'));
117        $currentTime = $now->format('H:i:s');
118        $today = $now->format('Y-m-d');
119
120        try {
121            // Get all lists for today ordered by start time
122            $stmt = $this->db->prepare("
123                SELECT dtl.*, tg.groupName,
124                       COALESCE(dls.startTime, dtl.startTime) as effectiveStartTime
125                FROM workbook_task_lists dtl
126                INNER JOIN taskGroups tg ON dtl.groupId = tg.id
127                LEFT JOIN workbook_list_schedule dls ON dtl.id = dls.listId
128                    AND dls.dayOfWeek = :dayOfWeek
129                    AND dls.isActive = 1
130                WHERE dtl.isActive = 1
131                  AND dtl.scheduledDays LIKE :dayOfWeekPattern
132                ORDER BY COALESCE(dls.startTime, dtl.startTime) ASC
133            ");
134            $stmt->execute([
135                ':dayOfWeek' => $dayOfWeek,
136                ':dayOfWeekPattern' => "%{$dayOfWeek}%"
137            ]);
138
139            $allLists = $stmt->fetchAll(PDO::FETCH_ASSOC);
140
141            if (empty($allLists)) {
142                return [
143                    'activeList' => null,
144                    'carryoverTasks' => [],
145                    'nextListTime' => null
146                ];
147            }
148
149            // Find the active list (most recent startTime that has passed)
150            $activeList = null;
151            $activeListIndex = -1;
152            $nextListTime = null;
153
154            foreach ($allLists as $index => $list) {
155                $startTime = $list['effectiveStartTime'];
156                if ($startTime === null || $startTime <= $currentTime) {
157                    $activeList = $list;
158                    $activeListIndex = $index;
159                } else {
160                    // This is the next upcoming list
161                    if ($nextListTime === null) {
162                        $nextListTime = $startTime;
163                    }
164                    break;
165                }
166            }
167
168            // If no list has started yet, return empty with next list time
169            if ($activeList === null) {
170                return [
171                    'activeList' => null,
172                    'carryoverTasks' => [],
173                    'nextListTime' => $allLists[0]['effectiveStartTime'] ?? null,
174                    'nextListName' => $allLists[0]['groupName'] ?? null
175                ];
176            }
177
178            // Get tasks for active list (filtered by time)
179            $activeList['tasks'] = $this->getTasksForList((int) $activeList['groupId'], true);
180            // Get all tasks for the day (for total count)
181            $allTasksForList = $this->getTasksForList((int) $activeList['groupId'], false);
182
183            $activeList['currentCount'] = count($activeList['tasks']); // Tasks due now
184            $activeList['totalCount'] = count($allTasksForList); // All tasks for today
185            $activeList['completedCount'] = 0;
186            foreach ($allTasksForList as $task) {
187                if (isset($task['status']) && $task['status'] == 2) {
188                    $activeList['completedCount']++;
189                }
190            }
191            // Percentage based on tasks due so far (not future tasks)
192            $activeList['completionPercentage'] = $this->calculateCompletionPercentage($activeList['tasks']);
193
194            // Collect carryover tasks from all previous lists (all tasks, not time-filtered)
195            $carryoverTasks = [];
196            for ($i = 0; $i < $activeListIndex; $i++) {
197                $prevList = $allLists[$i];
198                // Get all tasks from previous lists (they're all past due)
199                $prevTasks = $this->getTasksForList((int) $prevList['groupId'], false);
200
201                foreach ($prevTasks as $task) {
202                    // Include task if not completed (status != 2)
203                    if (!isset($task['status']) || $task['status'] != 2) {
204                        $task['carryoverFromList'] = $prevList['groupName'];
205                        $task['carryoverFromListId'] = $prevList['id'];
206                        $task['originalStartTime'] = $prevList['effectiveStartTime'];
207                        $carryoverTasks[] = $task;
208                    }
209                }
210            }
211
212            return [
213                'activeList' => $activeList,
214                'carryoverTasks' => $carryoverTasks,
215                'nextListTime' => $nextListTime,
216                'nextListName' => isset($allLists[$activeListIndex + 1]) ? $allLists[$activeListIndex + 1]['groupName'] : null
217            ];
218
219        } catch (PDOException $e) {
220            error_log("TaskListManager::getActiveListWithCarryover error: " . $e->getMessage());
221            return [
222                'activeList' => null,
223                'carryoverTasks' => [],
224                'nextListTime' => null
225            ];
226        }
227    }
228
229    /**
230     * Get the currently active task list based on startTime (legacy method)
231     *
232     * Returns the list with the most recent startTime that has already passed
233     *
234     * @return array|null Task list row or null if none active
235     */
236    public function getActiveList(): ?array
237    {
238        $result = $this->getActiveListWithCarryover();
239        return $result['activeList'];
240    }
241
242    /**
243     * Get tasks for a specific task list with today's completion status
244     *
245     * Joins tasks with workbook_task_completions and workbook_task_assignments
246     * to show completion status and assignment info for today's date.
247     * Only shows tasks where timeOfDay is NULL or has already passed.
248     *
249     * @param int $groupId Task group ID
250     * @param bool $filterByTime If true, only return tasks where timeOfDay <= current time (default: true)
251     * @return array Array of task rows with completion data
252     */
253    public function getTasksForList(int $groupId, bool $filterByTime = true): array
254    {
255        $timezone = new \DateTimeZone($this->store->timezone);
256        $now = new \DateTime('now', $timezone);
257        $today = $now->format('Y-m-d');
258        $currentTime = $now->format('H:i:s');
259        $dayOfWeek = strtoupper($now->format('D'));
260
261        try {
262            // Build the time filter condition
263            $timeFilter = '';
264            $params = [
265                ':groupId' => $groupId,
266                ':today' => $today,
267                ':dayOfWeek' => "%{$dayOfWeek}%"
268            ];
269
270            if ($filterByTime) {
271                $timeFilter = 'AND (t.timeOfDay IS NULL OR t.timeOfDay <= :currentTime)';
272                $params[':currentTime'] = $currentTime;
273            }
274
275            $stmt = $this->db->prepare("
276                SELECT
277                    t.*,
278                    dtc.id as completionId,
279                    dtc.status,
280                    dtc.completedBy,
281                    dtc.completedAt,
282                    dtc.notes as completionNotes,
283                    dta.employeeId as assignedToEmployeeId,
284                    dta.dueDate,
285                    dta.dueTime,
286                    e.employeeFirstName as completedByFirstName,
287                    e.employeeLastName as completedByLastName
288                FROM tasks t
289                LEFT JOIN workbook_task_completions dtc ON t.id = dtc.taskId AND dtc.date = :today
290                LEFT JOIN workbook_task_assignments dta ON t.id = dta.taskId AND (dta.dueDate IS NULL OR dta.dueDate = :today)
291                LEFT JOIN employees e ON dtc.completedBy = e.employeeID
292                WHERE t.taskGroup = :groupId
293                  AND t.recurOn LIKE :dayOfWeek
294                  {$timeFilter}
295                ORDER BY t.sortOrder ASC, t.priority ASC, t.taskName ASC
296            ");
297            $stmt->execute($params);
298
299            return $stmt->fetchAll(PDO::FETCH_ASSOC);
300        } catch (PDOException $e) {
301            error_log("TaskListManager::getTasksForList error: " . $e->getMessage());
302            return [];
303        }
304    }
305
306    /**
307     * Get ALL tasks for a specific task list (ignoring time filter)
308     *
309     * Useful for admin views or reporting where you want to see all tasks
310     * regardless of time of day.
311     *
312     * @param int $groupId Task group ID
313     * @return array Array of task rows with completion data
314     */
315    public function getAllTasksForList(int $groupId): array
316    {
317        return $this->getTasksForList($groupId, false);
318    }
319
320    /**
321     * Get schedule configuration for all lists on a specific day
322     *
323     * @param string $dayOfWeek Day of week (SUN, MON, etc.)
324     * @return array Schedule entries
325     */
326    public function getScheduleForDay(string $dayOfWeek): array
327    {
328        try {
329            $stmt = $this->db->prepare("
330                SELECT dls.*, dtl.displayName, tg.groupName
331                FROM workbook_list_schedule dls
332                JOIN workbook_task_lists dtl ON dls.listId = dtl.id
333                JOIN taskGroups tg ON dtl.groupId = tg.id
334                WHERE dls.dayOfWeek = :dayOfWeek
335                ORDER BY dls.startTime ASC
336            ");
337            $stmt->execute([':dayOfWeek' => $dayOfWeek]);
338            return $stmt->fetchAll(PDO::FETCH_ASSOC);
339        } catch (PDOException $e) {
340            error_log("TaskListManager::getScheduleForDay error: " . $e->getMessage());
341            return [];
342        }
343    }
344
345    /**
346     * Get full schedule configuration for all lists across all days
347     *
348     * @return array Schedule entries grouped by listId
349     */
350    public function getFullSchedule(): array
351    {
352        try {
353            $stmt = $this->db->prepare("
354                SELECT dls.*, dtl.displayName, tg.groupName
355                FROM workbook_list_schedule dls
356                JOIN workbook_task_lists dtl ON dls.listId = dtl.id
357                JOIN taskGroups tg ON dtl.groupId = tg.id
358                ORDER BY dls.listId, FIELD(dls.dayOfWeek, 'SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT')
359            ");
360            $stmt->execute();
361            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
362
363            // Group by listId
364            $schedule = [];
365            foreach ($rows as $row) {
366                $listId = $row['listId'];
367                if (!isset($schedule[$listId])) {
368                    $schedule[$listId] = [
369                        'listId' => $listId,
370                        'displayName' => $row['displayName'],
371                        'groupName' => $row['groupName'],
372                        'days' => []
373                    ];
374                }
375                $schedule[$listId]['days'][$row['dayOfWeek']] = [
376                    'id' => $row['id'],
377                    'startTime' => $row['startTime'],
378                    'isActive' => $row['isActive']
379                ];
380            }
381
382            return array_values($schedule);
383        } catch (PDOException $e) {
384            error_log("TaskListManager::getFullSchedule error: " . $e->getMessage());
385            return [];
386        }
387    }
388
389    /**
390     * Update schedule for a specific list and day
391     *
392     * @param int $listId Task list ID
393     * @param string $dayOfWeek Day of week
394     * @param string $startTime Start time (HH:MM:SS)
395     * @param bool $isActive Whether this schedule entry is active
396     * @return bool Success
397     */
398    public function updateSchedule(int $listId, string $dayOfWeek, string $startTime, bool $isActive = true): bool
399    {
400        try {
401            $stmt = $this->db->prepare("
402                INSERT INTO workbook_list_schedule (listId, dayOfWeek, startTime, isActive)
403                VALUES (:listId, :dayOfWeek, :startTime, :isActive)
404                ON DUPLICATE KEY UPDATE startTime = :startTime2, isActive = :isActive2
405            ");
406            $stmt->execute([
407                ':listId' => $listId,
408                ':dayOfWeek' => $dayOfWeek,
409                ':startTime' => $startTime,
410                ':isActive' => $isActive ? 1 : 0,
411                ':startTime2' => $startTime,
412                ':isActive2' => $isActive ? 1 : 0
413            ]);
414
415            $this->invalidateCache();
416            return true;
417        } catch (PDOException $e) {
418            error_log("TaskListManager::updateSchedule error: " . $e->getMessage());
419            return false;
420        }
421    }
422
423    /**
424     * Batch update schedules for a list
425     *
426     * @param int $listId Task list ID
427     * @param array $schedules Array of ['dayOfWeek' => 'startTime'] or ['dayOfWeek' => ['startTime' => '...', 'isActive' => bool]]
428     * @return bool Success
429     */
430    public function updateScheduleBatch(int $listId, array $schedules): bool
431    {
432        try {
433            $this->db->beginTransaction();
434
435            foreach ($schedules as $dayOfWeek => $config) {
436                if (is_string($config)) {
437                    $startTime = $config;
438                    $isActive = true;
439                } else {
440                    $startTime = $config['startTime'] ?? '09:00:00';
441                    $isActive = $config['isActive'] ?? true;
442                }
443
444                $this->updateSchedule($listId, $dayOfWeek, $startTime, $isActive);
445            }
446
447            $this->db->commit();
448            return true;
449        } catch (PDOException $e) {
450            $this->db->rollBack();
451            error_log("TaskListManager::updateScheduleBatch error: " . $e->getMessage());
452            return false;
453        }
454    }
455
456    /**
457     * Delete schedule entry for a specific list and day
458     *
459     * @param int $listId Task list ID
460     * @param string $dayOfWeek Day of week
461     * @return bool Success
462     */
463    public function deleteSchedule(int $listId, string $dayOfWeek): bool
464    {
465        try {
466            $stmt = $this->db->prepare("
467                DELETE FROM workbook_list_schedule
468                WHERE listId = :listId AND dayOfWeek = :dayOfWeek
469            ");
470            $stmt->execute([
471                ':listId' => $listId,
472                ':dayOfWeek' => $dayOfWeek
473            ]);
474
475            $this->invalidateCache();
476            return true;
477        } catch (PDOException $e) {
478            error_log("TaskListManager::deleteSchedule error: " . $e->getMessage());
479            return false;
480        }
481    }
482
483    /**
484     * Calculate completion percentage for a list of tasks
485     *
486     * @param array $tasks Array of task rows (must include 'status' field)
487     * @return int Percentage complete (0-100)
488     */
489    public function calculateCompletionPercentage(array $tasks): int
490    {
491        if (empty($tasks)) {
492            return 0;
493        }
494
495        $completedCount = 0;
496        foreach ($tasks as $task) {
497            if (isset($task['status']) && $task['status'] == 2) { // 2 = Completed
498                $completedCount++;
499            }
500        }
501
502        return (int) round(($completedCount / count($tasks)) * 100);
503    }
504
505    /**
506     * Create a new task list configuration
507     *
508     * @param array $data Task list data (groupId, displayName, scheduledDays, startTime, sortOrder, isActive)
509     * @return int The ID of the created task list
510     * @throws \Exception If groupId is missing or insert fails
511     */
512    public function createTaskList(array $data): int
513    {
514        if (!isset($data['groupId'])) {
515            throw new \Exception('groupId is required');
516        }
517
518        try {
519            $stmt = $this->db->prepare("
520                INSERT INTO workbook_task_lists
521                (groupId, displayName, scheduledDays, startTime, sortOrder, isActive)
522                VALUES
523                (:groupId, :displayName, :scheduledDays, :startTime, :sortOrder, :isActive)
524            ");
525
526            $stmt->execute([
527                ':groupId' => $data['groupId'],
528                ':displayName' => $data['displayName'] ?? null,
529                ':scheduledDays' => $data['scheduledDays'] ?? 'SUN MON TUE WED THU FRI SAT',
530                ':startTime' => $data['startTime'] ?? null,
531                ':sortOrder' => $data['sortOrder'] ?? 0,
532                ':isActive' => $data['isActive'] ?? 1
533            ]);
534
535            $id = (int) $this->db->lastInsertId();
536            $this->invalidateCache();
537
538            return $id;
539        } catch (PDOException $e) {
540            error_log("TaskListManager::createTaskList error: " . $e->getMessage());
541            throw new \Exception('Failed to create task list: ' . $e->getMessage());
542        }
543    }
544
545    /**
546     * Clear Redis cache for task lists and completions
547     *
548     * @return void
549     */
550    public function invalidateCache(): void
551    {
552        $cacheKeys = [
553            $this->store->getTypeNum() . '_workbook_task_lists',
554            $this->store->getTypeNum() . '_workbook_active_list',
555            $this->store->getTypeNum() . '_workbook_tasks_*'
556        ];
557
558        foreach ($cacheKeys as $pattern) {
559            if (strpos($pattern, '*') !== false) {
560                // Delete keys matching pattern
561                $keys = $this->cache->keys($pattern);
562                if (!empty($keys)) {
563                    $this->cache->del($keys);
564                }
565            } else {
566                $this->cache->del($pattern);
567            }
568        }
569    }
570}