diff --git a/app/Commands/AbdmPushSync.php b/app/Commands/AbdmPushSync.php index a9d28e7..3820419 100644 --- a/app/Commands/AbdmPushSync.php +++ b/app/Commands/AbdmPushSync.php @@ -52,7 +52,7 @@ class AbdmPushSync extends BaseCommand $tbService = new AbdmTaskBoardSyncService(); $tbSummary = $tbService->syncAll($limit); - CLI::write('OPD Consults -> Eligible: ' . ($tbSummary['opd']['eligible'] ?? 0) . ' | Linked: ' . ($tbSummary['opd']['linked'] ?? 0) . ' | Failed: ' . ($tbSummary['opd']['failed'] ?? 0), 'green'); - CLI::write('Work Tasks -> Eligible: ' . ($tbSummary['tasks']['eligible'] ?? 0) . ' | Linked: ' . ($tbSummary['tasks']['linked'] ?? 0) . ' | Failed: ' . ($tbSummary['tasks']['failed'] ?? 0), 'green'); + CLI::write('OPD Consults -> Eligible: ' . ($tbSummary['opd']['eligible'] ?? 0) . ' | Linked: ' . ($tbSummary['opd']['linked'] ?? 0) . ' | Cooling: ' . ($tbSummary['opd']['cooling'] ?? 0) . ' | Failed: ' . ($tbSummary['opd']['failed'] ?? 0), 'green'); + CLI::write('Work Tasks -> Eligible: ' . ($tbSummary['tasks']['eligible'] ?? 0) . ' | Linked: ' . ($tbSummary['tasks']['linked'] ?? 0) . ' | Cooling: ' . ($tbSummary['tasks']['cooling'] ?? 0) . ' | Failed: ' . ($tbSummary['tasks']['failed'] ?? 0), 'green'); } } diff --git a/app/Commands/AbdmTaskBoardSync.php b/app/Commands/AbdmTaskBoardSync.php index 1f3a69e..e895390 100644 --- a/app/Commands/AbdmTaskBoardSync.php +++ b/app/Commands/AbdmTaskBoardSync.php @@ -48,19 +48,24 @@ class AbdmTaskBoardSync extends BaseCommand CLI::write('Eligible : ' . $opdSummary['eligible']); CLI::write('Linked : ' . $opdSummary['linked'], 'green'); + CLI::write('Cooling : ' . ($opdSummary['cooling'] ?? 0), 'cyan'); CLI::write('Failed : ' . $opdSummary['failed'], $opdSummary['failed'] > 0 ? 'red' : 'green'); CLI::write('Skipped : ' . $opdSummary['skipped']); foreach ($opdSummary['details'] as $item) { $statusColor = match ($item['status'] ?? '') { - 'linked' => 'green', - 'failed' => 'red', - default => 'yellow', + 'linked' => 'green', + 'failed' => 'red', + 'cooling' => 'cyan', + default => 'yellow', }; + $coolingNote = (isset($item['status']) && $item['status'] === 'cooling') + ? ' [Cooling: ' . ($item['remaining_minutes'] ?? 0) . 'm left, auto-link at ' . ($item['auto_link_at'] ?? '') . ']' + : ''; $extra = isset($item['queue_id']) && $item['queue_id'] !== '' ? ' [Queue: ' . $item['queue_id'] . (isset($item['bridge_record_id']) ? ' Bridge: #' . $item['bridge_record_id'] : '') . ']' : (isset($item['error']) ? ' [Error: ' . $item['error'] . ']' : ''); - CLI::write(' OPD #' . ($item['opd_id'] ?? 0) . ' (' . ($item['patient'] ?? '') . ') -> ' . ($item['status'] ?? '') . $extra, $statusColor); + CLI::write(' OPD #' . ($item['opd_id'] ?? 0) . ' (' . ($item['patient'] ?? '') . ') -> ' . ($item['status'] ?? '') . $coolingNote . $extra, $statusColor); } } @@ -72,19 +77,24 @@ class AbdmTaskBoardSync extends BaseCommand CLI::write('Eligible : ' . $taskSummary['eligible']); CLI::write('Linked : ' . $taskSummary['linked'], 'green'); + CLI::write('Cooling : ' . ($taskSummary['cooling'] ?? 0), 'cyan'); CLI::write('Failed : ' . $taskSummary['failed'], $taskSummary['failed'] > 0 ? 'red' : 'green'); CLI::write('Skipped : ' . $taskSummary['skipped']); foreach ($taskSummary['details'] as $item) { $statusColor = match ($item['status'] ?? '') { - 'linked' => 'green', - 'failed' => 'red', - default => 'yellow', + 'linked' => 'green', + 'failed' => 'red', + 'cooling' => 'cyan', + default => 'yellow', }; + $coolingNote = (isset($item['status']) && $item['status'] === 'cooling') + ? ' [Cooling: ' . ($item['remaining_minutes'] ?? 0) . 'm left, auto-link at ' . ($item['auto_link_at'] ?? '') . ']' + : ''; $extra = isset($item['queue_id']) && $item['queue_id'] !== '' ? ' [Queue: ' . $item['queue_id'] . ']' : (isset($item['error']) ? ' [Error: ' . $item['error'] . ']' : ''); - CLI::write(' Task #' . ($item['task_id'] ?? 0) . ' (' . ($item['task_type'] ?? '') . ') -> ' . ($item['status'] ?? '') . $extra, $statusColor); + CLI::write(' Task #' . ($item['task_id'] ?? 0) . ' (' . ($item['task_type'] ?? '') . ') -> ' . ($item['status'] ?? '') . $coolingNote . $extra, $statusColor); } } diff --git a/app/Config/AbdmConnector.php b/app/Config/AbdmConnector.php index 8285187..dd8feb4 100644 --- a/app/Config/AbdmConnector.php +++ b/app/Config/AbdmConnector.php @@ -144,4 +144,19 @@ class AbdmConnector extends BaseConfig // REQUIRED on production; falls back to a derived key in development. // ------------------------------------------------------------------ // ABDM_FHIR_ENCRYPTION_KEY = + + // ------------------------------------------------------------------ + // Automated Care-Context Linking Grace / Cooling Period + // Prevents premature data push while staff/doctors may still modify + // prescriptions, lab reports, radiology reports, or discharge summaries. + // Manual push from ABDM Task Board always bypasses this cooling period. + // ------------------------------------------------------------------ + public bool $autoLinkEnabled = true; + + /** Default cooling period for OPD, Lab, Radiology, and all other HI types (in minutes). */ + public int $autoLinkDelayMinutes = 60; + + /** Cooling period for IPD Discharge Summary (in hours). */ + public int $autoLinkDelayDischargeHours = 24; } + diff --git a/app/Controllers/AbdmTaskBoard.php b/app/Controllers/AbdmTaskBoard.php index 06bbb43..5825743 100644 --- a/app/Controllers/AbdmTaskBoard.php +++ b/app/Controllers/AbdmTaskBoard.php @@ -4,6 +4,7 @@ namespace App\Controllers; use App\Libraries\AbdmWorkTaskService; use App\Libraries\BridgeSyncService; +use App\Libraries\Abdm\Sync\AbdmTaskBoardSyncService; class AbdmTaskBoard extends BaseController { @@ -21,7 +22,7 @@ class AbdmTaskBoard extends BaseController $this->backfillImmunizationTasks(); $this->backfillHealthDocumentTasks(); $this->backfillWellnessTasks(); - $tasks = $this->enrichImmunizationPushState($this->taskService->getOpenTasks(300)); + $tasks = $this->enrichTasksWithCoolingState($this->enrichImmunizationPushState($this->taskService->getOpenTasks(300))); $dateFrom = trim((string) ($this->request->getGet('date_from') ?? date('Y-m-d'))); $dateTo = trim((string) ($this->request->getGet('date_to') ?? date('Y-m-d'))); @@ -56,7 +57,7 @@ class AbdmTaskBoard extends BaseController $this->backfillWellnessTasks(); return $this->response->setJSON([ 'ok' => 1, - 'tasks' => $this->enrichImmunizationPushState($this->taskService->getOpenTasks(300)), + 'tasks' => $this->enrichTasksWithCoolingState($this->enrichImmunizationPushState($this->taskService->getOpenTasks(300))), 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); @@ -137,6 +138,49 @@ class AbdmTaskBoard extends BaseController return $tasks; } + /** @param array> $tasks */ + private function enrichTasksWithCoolingState(array $tasks): array + { + if (empty($tasks)) { + return []; + } + + foreach ($tasks as &$task) { + $taskType = (string) ($task['task_type'] ?? ''); + $lastModified = ! empty($task['updated_at']) + ? (string) $task['updated_at'] + : (! empty($task['created_at']) ? (string) $task['created_at'] : null); + + $cooling = AbdmTaskBoardSyncService::calculateCooling($taskType, $lastModified); + + $task['cooling_active'] = ! empty($cooling['is_cooling_active']); + $task['cooling_remaining_minutes'] = (int) ($cooling['remaining_minutes'] ?? 0); + $task['cooling_remaining_seconds'] = (int) ($cooling['remaining_seconds'] ?? 0); + $task['auto_link_at'] = $cooling['auto_link_at'] ?? null; + $task['ready_for_autolink'] = false; + + if ($task['cooling_active']) { + $remMin = $task['cooling_remaining_minutes']; + if ($remMin >= 60) { + $hours = intdiv($remMin, 60); + $mins = $remMin % 60; + $timeStr = $mins > 0 ? "{$hours}h {$mins}m" : "{$hours}h"; + } else { + $timeStr = "{$remMin}m"; + } + $task['cooling_label'] = "Cooling ({$timeStr} left)"; + $task['cooling_tooltip'] = "Cooling active until {$task['auto_link_at']} to allow clinician edits. Modifications reset the timer. Click Link to bypass."; + } elseif (! empty($task['auto_link_at']) && $task['auto_link_at'] !== 'disabled') { + $task['ready_for_autolink'] = true; + $task['cooling_label'] = "Ready for Link"; + $task['cooling_tooltip'] = "Cooling period elapsed at {$task['auto_link_at']}. Queued for automated linking."; + } + } + unset($task); + + return $tasks; + } + public function performAction() { if (! $this->request->isAJAX()) { @@ -920,7 +964,7 @@ class AbdmTaskBoard extends BaseController $latestDocByOpd = []; if (! empty($opdIds) && $this->db->tableExists('opd_fhir_documents')) { $docRows = $this->db->table('opd_fhir_documents') - ->select('id, opd_id, opd_session_id, generated_at, created_at') + ->select('id, opd_id, opd_session_id, generated_at, created_at, updated_at') ->whereIn('opd_id', $opdIds) ->whereIn('bundle_type', ['OPConsultRecord', 'MedicationRequestBundle', 'PrescriptionRecord']) ->orderBy('id', 'DESC') @@ -1019,12 +1063,25 @@ class AbdmTaskBoard extends BaseController $statusTone = 'secondary'; $statusNote = 'FHIR not registered in health_records yet.'; + $docModified = null; if ($doc !== null) { $statusLabel = 'FHIR Generated'; $statusTone = 'info'; $statusNote = 'FHIR bundle generated; awaiting registration.'; + + $docTimestamps = array_filter([ + ! empty($doc['updated_at']) ? (string) $doc['updated_at'] : null, + ! empty($doc['generated_at']) ? (string) $doc['generated_at'] : null, + ! empty($doc['created_at']) ? (string) $doc['created_at'] : null, + ]); + $docModified = ! empty($docTimestamps) ? max($docTimestamps) : null; } + $cooling = AbdmTaskBoardSyncService::calculateCooling('opd_prescription_publish', $docModified); + $isCoolingActive = ! empty($cooling['is_cooling_active']); + $coolingRemainingMinutes = (int) ($cooling['remaining_minutes'] ?? 0); + $autoLinkAt = $cooling['auto_link_at'] ?? null; + if ($pushStatus !== '') { if ($pushStatus === 'local_discovery_ready') { $statusLabel = 'Discovery Ready'; @@ -1061,6 +1118,28 @@ class AbdmTaskBoard extends BaseController $statusNote = 'Link callback reported failure.'; } + // If not yet linked or submitted to gateway, reflect cooling window state + $isAlreadyLinkedOrQueued = in_array($linkStatus, ['linked', 'registered'], true) || in_array($pushStatus, ['linked', 'pushed', 'queued'], true); + if ($doc !== null && ! $isAlreadyLinkedOrQueued) { + if ($isCoolingActive) { + $remMin = $coolingRemainingMinutes; + if ($remMin >= 60) { + $hours = intdiv($remMin, 60); + $mins = $remMin % 60; + $timeStr = $mins > 0 ? "{$hours}h {$mins}m" : "{$hours}h"; + } else { + $timeStr = "{$remMin}m"; + } + $statusLabel = 'Cooling (' . $timeStr . ' left)'; + $statusTone = 'warning'; + $statusNote = 'Cooling active until ' . $autoLinkAt . ' to allow clinician prescription edits before auto-link.'; + } elseif (! empty($autoLinkAt) && $autoLinkAt !== 'disabled') { + $statusLabel = 'Ready for Auto-Link'; + $statusTone = 'primary'; + $statusNote = 'Cooling period elapsed. Ready for automated link push.'; + } + } + $queueId = trim((string) ($hr['abdm_txn_id'] ?? '')); $bridgeRecordId = (int) ($hr['bridge_record_id'] ?? 0); @@ -1075,6 +1154,9 @@ class AbdmTaskBoard extends BaseController 'queue_id' => $queueId, 'bridge_record_id' => $bridgeRecordId > 0 ? $bridgeRecordId : null, 'has_fhir' => $doc !== null ? 1 : 0, + 'cooling_active' => $isCoolingActive ? 1 : 0, + 'cooling_remaining_minutes' => $coolingRemainingMinutes, + 'auto_link_at' => $autoLinkAt, ]); } diff --git a/app/Controllers/Ipd_discharge.php b/app/Controllers/Ipd_discharge.php index e6e306e..905718f 100644 --- a/app/Controllers/Ipd_discharge.php +++ b/app/Controllers/Ipd_discharge.php @@ -1640,33 +1640,44 @@ class Ipd_discharge extends BaseController ->get(1) ->getRowArray(); + $saved = false; if (! empty($existing['id'])) { - return (bool) $builder + $saved = (bool) $builder ->where('id', (int) $existing['id']) ->update(['content' => $content]); + } else { + $insert = [ + 'ipd_id' => $ipdId, + 'content' => $content, + ]; + + // Legacy schema has NOT NULL columns without defaults. + $userLabel = substr($this->currentUserLabel(), 0, 50); + if ($this->db->fieldExists('created_by', 'ipd_discharge')) { + $insert['created_by'] = $userLabel; + } + if ($this->db->fieldExists('checked_by', 'ipd_discharge')) { + $insert['checked_by'] = $userLabel; + } + if ($this->db->fieldExists('created_datetime', 'ipd_discharge')) { + $insert['created_datetime'] = date('Y-m-d H:i:s'); + } + if ($this->db->fieldExists('ipd_discharge_print', 'ipd_discharge')) { + $insert['ipd_discharge_print'] = 0; + } + + $saved = (bool) $builder->insert($insert); } - $insert = [ - 'ipd_id' => $ipdId, - 'content' => $content, - ]; - - // Legacy schema has NOT NULL columns without defaults. - $userLabel = substr($this->currentUserLabel(), 0, 50); - if ($this->db->fieldExists('created_by', 'ipd_discharge')) { - $insert['created_by'] = $userLabel; - } - if ($this->db->fieldExists('checked_by', 'ipd_discharge')) { - $insert['checked_by'] = $userLabel; - } - if ($this->db->fieldExists('created_datetime', 'ipd_discharge')) { - $insert['created_datetime'] = date('Y-m-d H:i:s'); - } - if ($this->db->fieldExists('ipd_discharge_print', 'ipd_discharge')) { - $insert['ipd_discharge_print'] = 0; + if ($saved && $this->db->tableExists('abdm_work_tasks')) { + $this->db->table('abdm_work_tasks') + ->where('task_type', 'ipd_discharge_publish') + ->where('entity_id', (string) $ipdId) + ->whereIn('status', ['pending', 'in_progress']) + ->update(['updated_at' => date('Y-m-d H:i:s')]); } - return (bool) $builder->insert($insert); + return $saved; } catch (\Throwable $e) { log_message('error', 'Discharge content save failed for IPD {ipd}: {msg}', [ 'ipd' => $ipdId, diff --git a/app/Controllers/Opd_prescription.php b/app/Controllers/Opd_prescription.php index 1c7a9ce..b8b4a05 100644 --- a/app/Controllers/Opd_prescription.php +++ b/app/Controllers/Opd_prescription.php @@ -703,6 +703,7 @@ class Opd_prescription extends BaseController $fhirStored = false; try { $fhirStored = $this->storePrescriptionFhirBundle((int) $opdId, (int) $recordId, $patientRow, (array) $opdRow); + $this->createOpdPrescriptionWorkTask((int) $opdId, (int) $recordId); } catch (\Throwable $fhirEx) { log_message('error', 'FHIR bundle store failed during opd save: ' . $fhirEx->getMessage() . ' in ' . $fhirEx->getFile() . ':' . $fhirEx->getLine()); } @@ -11790,14 +11791,36 @@ class Opd_prescription extends BaseController } $generatedBy = (string) ($userId ?? session('user_id') ?? 'system'); - $inserted = (bool) $this->db->table('opd_fhir_documents')->insert([ - 'opd_id' => $opdId, - 'opd_session_id' => $sessionId, - 'bundle_type' => 'OPConsultRecord', - 'bundle_json' => $bundleJson, - 'generated_by' => $generatedBy, - 'generated_at' => Time::now('Asia/Kolkata')->toDateTimeString(), - ]); + $now = Time::now('Asia/Kolkata')->toDateTimeString(); + $existingDoc = $this->db->table('opd_fhir_documents') + ->where('opd_id', $opdId) + ->where('opd_session_id', $sessionId) + ->where('bundle_type', 'OPConsultRecord') + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + if (! empty($existingDoc['id'])) { + $inserted = (bool) $this->db->table('opd_fhir_documents') + ->where('id', (int) $existingDoc['id']) + ->update([ + 'bundle_json' => $bundleJson, + 'generated_by' => $generatedBy, + 'generated_at' => $now, + 'updated_at' => $now, + ]); + } else { + $inserted = (bool) $this->db->table('opd_fhir_documents')->insert([ + 'opd_id' => $opdId, + 'opd_session_id' => $sessionId, + 'bundle_type' => 'OPConsultRecord', + 'bundle_json' => $bundleJson, + 'generated_by' => $generatedBy, + 'generated_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } if (! $inserted) { return false; diff --git a/app/Libraries/Abdm/Sync/AbdmTaskBoardSyncService.php b/app/Libraries/Abdm/Sync/AbdmTaskBoardSyncService.php index 8d5e269..f232d41 100644 --- a/app/Libraries/Abdm/Sync/AbdmTaskBoardSyncService.php +++ b/app/Libraries/Abdm/Sync/AbdmTaskBoardSyncService.php @@ -58,6 +58,7 @@ class AbdmTaskBoardSyncService $summary = [ 'eligible' => 0, 'linked' => 0, + 'cooling' => 0, 'failed' => 0, 'skipped' => 0, 'details' => [], @@ -200,8 +201,6 @@ class AbdmTaskBoardSyncService continue; } - $summary['eligible']++; - $consultDate = (string) ($row['apointment_date'] ?? ''); $visitDate = $consultDate !== '' ? date('Y-m-d', strtotime($consultDate)) : date('Y-m-d'); $derivedCcRef = 'OPD-' . $opdId . '-S' . ($sessionId > 0 ? $sessionId : 0) . '-' . $visitDate; @@ -211,6 +210,33 @@ class AbdmTaskBoardSyncService $careContextRef = $derivedCcRef; } + // Check cooling period from the latest modification/generation timestamp + $docTimestamps = array_filter([ + ! empty($doc['updated_at']) ? (string) $doc['updated_at'] : null, + ! empty($doc['generated_at']) ? (string) $doc['generated_at'] : null, + ! empty($hr['updated_at']) ? (string) $hr['updated_at'] : null, + ! empty($doc['created_at']) ? (string) $doc['created_at'] : null, + ]); + $lastModified = ! empty($docTimestamps) ? max($docTimestamps) : ($row['apointment_date'] ?? date('Y-m-d H:i:s')); + + $cooling = self::calculateCooling('opd_prescription_publish', $lastModified); + if ($cooling['is_cooling_active']) { + $summary['cooling']++; + $summary['details'][] = [ + 'opd_id' => $opdId, + 'patient' => trim((string) ($row['P_name'] ?? '')), + 'abha' => trim((string) ($row['abha_id'] ?? '')), + 'care_context' => $careContextRef, + 'last_modified' => $lastModified, + 'remaining_minutes' => $cooling['remaining_minutes'], + 'auto_link_at' => $cooling['auto_link_at'], + 'status' => 'cooling', + ]; + continue; + } + + $summary['eligible']++; + if ($dryRun) { $summary['details'][] = [ 'opd_id' => $opdId, @@ -521,6 +547,7 @@ class AbdmTaskBoardSyncService $summary = [ 'eligible' => 0, 'linked' => 0, + 'cooling' => 0, 'failed' => 0, 'skipped' => 0, 'details' => [], @@ -552,21 +579,6 @@ class AbdmTaskBoardSyncService return $summary; } - $summary['eligible'] = count($tasks); - - if ($dryRun) { - foreach ($tasks as $t) { - $summary['details'][] = [ - 'task_id' => (int) $t['id'], - 'task_type' => $t['task_type'], - 'entity_id' => $t['entity_id'], - 'patient' => $t['patient_name'] ?? '', - 'status' => 'dry_run_eligible', - ]; - } - return $summary; - } - foreach ($tasks as $task) { $taskId = (int) $task['id']; $taskType = (string) $task['task_type']; @@ -574,6 +586,38 @@ class AbdmTaskBoardSyncService $patientId = (int) ($task['patient_id'] ?? 0); $abhaId = trim((string) ($task['abha_id'] ?? '')); + // Check cooling period from the latest modification/refresh timestamp + $lastModified = ! empty($task['updated_at']) ? $task['updated_at'] : ($task['created_at'] ?? date('Y-m-d H:i:s')); + $cooling = self::calculateCooling($taskType, $lastModified); + + if ($cooling['is_cooling_active']) { + $summary['cooling']++; + $summary['details'][] = [ + 'task_id' => $taskId, + 'task_type' => $taskType, + 'entity_id' => $entityId, + 'patient' => $task['patient_name'] ?? '', + 'last_modified' => $lastModified, + 'remaining_minutes' => $cooling['remaining_minutes'], + 'auto_link_at' => $cooling['auto_link_at'], + 'status' => 'cooling', + ]; + continue; + } + + if ($dryRun) { + $summary['eligible']++; + $summary['details'][] = [ + 'task_id' => $taskId, + 'task_type' => $taskType, + 'entity_id' => $entityId, + 'patient' => $task['patient_name'] ?? '', + 'status' => 'dry_run_eligible', + ]; + continue; + } + + $summary['eligible']++; $result = $this->processIndividualWorkTask($task); if ($result['ok'] === 1) { @@ -811,4 +855,90 @@ class AbdmTaskBoardSyncService return null; } + + /** + * Calculate cooling period state for any task or document. + * + * Rules: + * - IPD Discharge Summary: 24 hours from last modified/printed time. + * - All other HI types (OPD Consult/Prescription, Lab, Radiology, Wellness, Immunization, etc.): + * 60 minutes from last modified/updated/printed time. + * - Any modification/update in HMS refreshes the timestamp, resetting the cooling period. + * + * @param string $taskType Task type, HI type, or entity name + * @param string|null $lastModified DateTime string of the last update/generation + * @return array{ + * is_cooling_active: bool, + * remaining_seconds: int, + * remaining_minutes: int, + * required_seconds: int, + * last_modified: string, + * auto_link_at: string + * } + */ + public static function calculateCooling(string $taskType, ?string $lastModified): array + { + $cfg = config('AbdmConnector'); + $autoLinkEnabled = (bool) ($cfg->autoLinkEnabled ?? true); + if (! $autoLinkEnabled) { + return [ + 'is_cooling_active' => true, + 'cooling_active' => true, + 'remaining_seconds' => PHP_INT_MAX, + 'remaining_minutes' => PHP_INT_MAX, + 'required_seconds' => PHP_INT_MAX, + 'last_modified' => (string) $lastModified, + 'auto_link_at' => 'disabled', + ]; + } + + $typeLower = strtolower(trim($taskType)); + if (in_array($typeLower, ['ipd_discharge_publish', 'ipd_discharge', 'dischargesummaryrecord', 'ipd'], true)) { + $hours = max(1, (int) ($cfg->autoLinkDelayDischargeHours ?? 24)); + $requiredSeconds = $hours * 3600; + } else { + $minutes = max(1, (int) ($cfg->autoLinkDelayMinutes ?? 60)); + $requiredSeconds = $minutes * 60; + } + + $lastTimestamp = ! empty($lastModified) ? strtotime($lastModified) : 0; + if ($lastTimestamp <= 0) { + $lastTimestamp = time(); + } + + $now = time(); + $elapsedSeconds = max(0, $now - $lastTimestamp); + $remainingSeconds = max(0, $requiredSeconds - $elapsedSeconds); + $isCoolingActive = ($remainingSeconds > 0); + $remainingMinutes = (int) ceil($remainingSeconds / 60); + $autoLinkAt = date('Y-m-d H:i:s', $lastTimestamp + $requiredSeconds); + + return [ + 'is_cooling_active' => $isCoolingActive, + 'cooling_active' => $isCoolingActive, + 'remaining_seconds' => $remainingSeconds, + 'remaining_minutes' => $remainingMinutes, + 'required_seconds' => $requiredSeconds, + 'last_modified' => (string) $lastModified, + 'auto_link_at' => $autoLinkAt, + ]; + } + + /** + * Instance wrapper for calculateCooling. + * + * @return array{ + * is_cooling_active: bool, + * remaining_seconds: int, + * remaining_minutes: int, + * required_seconds: int, + * last_modified: string, + * auto_link_at: string + * } + */ + public function resolveCoolingState(string $taskType, ?string $lastModified): array + { + return self::calculateCooling($taskType, $lastModified); + } } + diff --git a/app/Views/abdm/task_board.php b/app/Views/abdm/task_board.php index d5b4a92..d32e1a6 100644 --- a/app/Views/abdm/task_board.php +++ b/app/Views/abdm/task_board.php @@ -5,6 +5,7 @@ ABDM Work Task Board +