[ 'key' => 'staff', 'name' => 'Amina Staff', 'email' => 'amina.staff@example.com', 'role' => 'Staff', 'level' => 'Staff', 'department' => 'Operations', ], 'approver_team' => [ 'key' => 'approver_team', 'name' => 'Noah Team Lead', 'email' => 'noah.team@example.com', 'role' => 'Approver', 'level' => 'Team', 'department' => 'Operations', ], 'approver_manager' => [ 'key' => 'approver_manager', 'name' => 'David Manager', 'email' => 'david.manager@example.com', 'role' => 'Approver', 'level' => 'Manager', 'department' => 'Operations', ], 'approver_director' => [ 'key' => 'approver_director', 'name' => 'Lina Director', 'email' => 'lina.director@example.com', 'role' => 'Approver', 'level' => 'Director', 'department' => 'Strategy', ], 'approver_ceo' => [ 'key' => 'approver_ceo', 'name' => 'Joseph CEO', 'email' => 'joseph.ceo@example.com', 'role' => 'Approver', 'level' => 'CEO', 'department' => 'Executive', ], 'admin' => [ 'key' => 'admin', 'name' => 'Rita Admin', 'email' => 'rita.admin@example.com', 'role' => 'Admin', 'level' => 'Admin', 'department' => 'People Ops', ], ]; } function okr_current_profile(): array { $profiles = okr_profiles(); $key = $_SESSION['okr_profile'] ?? 'staff'; if (!isset($profiles[$key])) { $key = 'staff'; } return $profiles[$key]; } function okr_set_profile(string $key): void { $profiles = okr_profiles(); if (isset($profiles[$key])) { $_SESSION['okr_profile'] = $key; } } function okr_csrf_token(): string { if (empty($_SESSION['okr_csrf'])) { $_SESSION['okr_csrf'] = bin2hex(random_bytes(16)); } return (string) $_SESSION['okr_csrf']; } function okr_verify_csrf(): void { $posted = $_POST['csrf_token'] ?? ''; if (!hash_equals(okr_csrf_token(), (string) $posted)) { throw new RuntimeException('Your session expired. Refresh the page and try again.'); } } function okr_flash(?string $type = null, ?string $message = null): ?array { if ($type !== null && $message !== null) { $_SESSION['okr_flash'] = ['type' => $type, 'message' => $message]; return null; } if (empty($_SESSION['okr_flash']) || !is_array($_SESSION['okr_flash'])) { return null; } $flash = $_SESSION['okr_flash']; unset($_SESSION['okr_flash']); return $flash; } function okr_level_rank(string $level): int { return match ($level) { 'Team' => 1, 'Manager' => 2, 'Director' => 3, 'CEO' => 4, 'Admin' => 5, default => 0, }; } function okr_is_admin(array $profile): bool { return ($profile['role'] ?? '') === 'Admin'; } function okr_is_approver(array $profile): bool { return ($profile['role'] ?? '') === 'Approver' || okr_is_admin($profile); } function okr_require_schema(): void { static $ready = false; if ($ready) { return; } $sql = <<exec($sql); $ready = true; } function okr_clean_text(string $value, int $max = 190): string { $value = trim($value); if ($value === '') { return ''; } return function_exists('mb_substr') ? mb_substr($value, 0, $max) : substr($value, 0, $max); } function okr_parse_json_field(?string $value): array { if ($value === null || trim($value) === '') { return []; } $decoded = json_decode($value, true); return is_array($decoded) ? $decoded : []; } function okr_safe_score(mixed $value): float { if ($value === null || $value === '') { return 0.0; } $score = (float) $value; if ($score < 0) { $score = 0; } if ($score > 100) { $score = 100; } return round($score, 2); } function okr_effective_score(array $keyResult, string $status): float { if ($status === 'Approved' && isset($keyResult['manager_score']) && $keyResult['manager_score'] !== null && $keyResult['manager_score'] !== '') { return okr_safe_score($keyResult['manager_score']); } return okr_safe_score($keyResult['owner_score'] ?? 0); } function okr_calculate_objective_score(array $keyResults, string $status): float { if ($keyResults === []) { return 0.0; } $total = 0.0; $count = 0; foreach ($keyResults as $keyResult) { $total += okr_effective_score($keyResult, $status); $count++; } return $count > 0 ? round($total / $count, 1) : 0.0; } function okr_activity_item(array $profile, string $message, string $kind = 'update'): array { return [ 'time' => gmdate('Y-m-d H:i:s'), 'actor_name' => $profile['name'] ?? 'System', 'actor_role' => $profile['role'] ?? 'System', 'actor_level' => $profile['level'] ?? '', 'kind' => $kind, 'message' => $message, ]; } function okr_comment_item(array $profile, string $message): array { return [ 'time' => gmdate('Y-m-d H:i:s'), 'actor_name' => $profile['name'] ?? 'System', 'actor_role' => $profile['role'] ?? 'System', 'message' => $message, ]; } function okr_prepare_entry(array $row): array { $row['key_results'] = okr_parse_json_field($row['key_results_json'] ?? '[]'); $row['comments'] = okr_parse_json_field($row['comments_json'] ?? '[]'); $row['activity'] = okr_parse_json_field($row['activity_json'] ?? '[]'); $row['objective_score'] = (float) ($row['objective_score'] ?? 0); $row['key_result_count'] = count($row['key_results']); $completed = 0; foreach ($row['key_results'] as $keyResult) { if (okr_effective_score($keyResult, (string) ($row['status'] ?? 'Draft')) >= 70) { $completed++; } } $row['completed_key_results'] = $completed; return $row; } function okr_fetch_entries(?string $status = null): array { okr_require_schema(); $sql = 'SELECT * FROM okr_entries'; $params = []; if ($status !== null && in_array($status, ['Draft', 'Pending', 'Approved'], true)) { $sql .= ' WHERE status = :status'; $params[':status'] = $status; } $sql .= ' ORDER BY updated_at DESC, id DESC'; $stmt = db()->prepare($sql); foreach ($params as $key => $value) { $stmt->bindValue($key, $value); } $stmt->execute(); return array_map('okr_prepare_entry', $stmt->fetchAll()); } function okr_fetch_entry(int $id): ?array { okr_require_schema(); $stmt = db()->prepare('SELECT * FROM okr_entries WHERE id = :id LIMIT 1'); $stmt->bindValue(':id', $id, PDO::PARAM_INT); $stmt->execute(); $row = $stmt->fetch(); return $row ? okr_prepare_entry($row) : null; } function okr_can_edit_owner(array $entry, array $profile): bool { return okr_is_admin($profile) || (($profile['email'] ?? '') === ($entry['owner_email'] ?? '')); } function okr_can_review(array $entry, array $profile): bool { if (okr_is_admin($profile)) { return true; } if (!okr_is_approver($profile)) { return false; } return okr_level_rank((string) ($profile['level'] ?? '')) >= okr_level_rank((string) ($entry['approver_level'] ?? '')); } function okr_normalize_key_results(array $titles, array $dueDates = [], array $ownerScores = [], array $managerScores = []): array { $results = []; foreach ($titles as $index => $title) { $cleanTitle = okr_clean_text((string) $title, 190); if ($cleanTitle === '') { continue; } $dueDate = okr_clean_text((string) ($dueDates[$index] ?? ''), 20); $results[] = [ 'title' => $cleanTitle, 'due_date' => $dueDate, 'owner_score' => okr_safe_score($ownerScores[$index] ?? 0), 'manager_score' => ($managerScores[$index] ?? '') === '' ? null : okr_safe_score($managerScores[$index]), ]; } return $results; } function okr_create_entry(array $payload, array $actor): int { okr_require_schema(); $keyResults = okr_normalize_key_results( $payload['key_result_title'] ?? [], $payload['key_result_due'] ?? [] ); if (count($keyResults) === 0) { throw new RuntimeException('Add at least one key result before saving the objective.'); } $objectiveTitle = okr_clean_text((string) ($payload['objective_title'] ?? ''), 190); $ownerName = okr_clean_text((string) ($payload['owner_name'] ?? ''), 120); $ownerEmail = filter_var((string) ($payload['owner_email'] ?? ''), FILTER_VALIDATE_EMAIL) ?: ''; $departmentName = okr_clean_text((string) ($payload['department_name'] ?? ''), 120); $periodLabel = okr_clean_text((string) ($payload['period_label'] ?? ''), 60); $approverName = okr_clean_text((string) ($payload['approver_name'] ?? ''), 120); $approverLevel = okr_clean_text((string) ($payload['approver_level'] ?? ''), 40); if ($objectiveTitle === '' || $ownerName === '' || $ownerEmail === '' || $departmentName === '' || $periodLabel === '' || $approverName === '' || $approverLevel === '') { throw new RuntimeException('Complete all required fields before creating the objective.'); } if (!in_array($approverLevel, ['Team', 'Manager', 'Director', 'CEO'], true)) { throw new RuntimeException('Select a valid approver level.'); } $activity = [okr_activity_item($actor, 'Draft objective created with ' . count($keyResults) . ' key result(s).', 'created')]; $score = okr_calculate_objective_score($keyResults, 'Draft'); $stmt = db()->prepare( 'INSERT INTO okr_entries ( objective_title, owner_name, owner_email, owner_role, department_name, period_label, approver_name, approver_level, status, objective_score, key_results_json, comments_json, activity_json ) VALUES ( :objective_title, :owner_name, :owner_email, :owner_role, :department_name, :period_label, :approver_name, :approver_level, :status, :objective_score, :key_results_json, :comments_json, :activity_json )' ); $stmt->bindValue(':objective_title', $objectiveTitle); $stmt->bindValue(':owner_name', $ownerName); $stmt->bindValue(':owner_email', $ownerEmail); $stmt->bindValue(':owner_role', (string) ($actor['role'] ?? 'Staff')); $stmt->bindValue(':department_name', $departmentName); $stmt->bindValue(':period_label', $periodLabel); $stmt->bindValue(':approver_name', $approverName); $stmt->bindValue(':approver_level', $approverLevel); $stmt->bindValue(':status', 'Draft'); $stmt->bindValue(':objective_score', $score); $stmt->bindValue(':key_results_json', json_encode($keyResults, JSON_UNESCAPED_UNICODE)); $stmt->bindValue(':comments_json', json_encode([], JSON_UNESCAPED_UNICODE)); $stmt->bindValue(':activity_json', json_encode($activity, JSON_UNESCAPED_UNICODE)); $stmt->execute(); return (int) db()->lastInsertId(); } function okr_update_entry_record(array $entry, array $keyResults, array $comments, array $activity, string $status, ?string $submittedAt = null, ?string $approvedAt = null): void { $score = okr_calculate_objective_score($keyResults, $status); $stmt = db()->prepare( 'UPDATE okr_entries SET status = :status, objective_score = :objective_score, key_results_json = :key_results_json, comments_json = :comments_json, activity_json = :activity_json, submitted_at = :submitted_at, approved_at = :approved_at WHERE id = :id' ); $stmt->bindValue(':status', $status); $stmt->bindValue(':objective_score', $score); $stmt->bindValue(':key_results_json', json_encode($keyResults, JSON_UNESCAPED_UNICODE)); $stmt->bindValue(':comments_json', json_encode($comments, JSON_UNESCAPED_UNICODE)); $stmt->bindValue(':activity_json', json_encode($activity, JSON_UNESCAPED_UNICODE)); $stmt->bindValue(':submitted_at', $submittedAt); $stmt->bindValue(':approved_at', $approvedAt); $stmt->bindValue(':id', (int) $entry['id'], PDO::PARAM_INT); $stmt->execute(); } function okr_update_owner_scores(int $id, array $ownerScores, array $actor): void { $entry = okr_fetch_entry($id); if (!$entry) { throw new RuntimeException('Objective not found.'); } if (!okr_can_edit_owner($entry, $actor) && !okr_is_admin($actor)) { throw new RuntimeException('You can only update self-scores for your own objectives.'); } $keyResults = $entry['key_results']; foreach ($keyResults as $index => &$keyResult) { if (array_key_exists($index, $ownerScores)) { $keyResult['owner_score'] = okr_safe_score($ownerScores[$index]); } } unset($keyResult); $entry['activity'][] = okr_activity_item($actor, 'Owner scores updated.', 'score'); okr_update_entry_record( $entry, $keyResults, $entry['comments'], $entry['activity'], (string) $entry['status'], $entry['submitted_at'] ?: null, $entry['approved_at'] ?: null ); } function okr_submit_entry(int $id, array $actor): void { $entry = okr_fetch_entry($id); if (!$entry) { throw new RuntimeException('Objective not found.'); } if (!okr_can_edit_owner($entry, $actor) && !okr_is_admin($actor)) { throw new RuntimeException('Only the objective owner or admin can submit this OKR.'); } $status = 'Pending'; $approvedAt = null; if (($entry['approver_level'] ?? '') === 'CEO') { $status = 'Approved'; $approvedAt = gmdate('Y-m-d H:i:s'); foreach ($entry['key_results'] as &$keyResult) { $keyResult['manager_score'] = okr_safe_score($keyResult['owner_score'] ?? 0); } unset($keyResult); $entry['activity'][] = okr_activity_item($actor, 'Submitted and auto-approved because the approver level is CEO.', 'approved'); } else { $entry['activity'][] = okr_activity_item($actor, 'Submitted for approval to ' . $entry['approver_name'] . '.', 'submitted'); } okr_update_entry_record( $entry, $entry['key_results'], $entry['comments'], $entry['activity'], $status, gmdate('Y-m-d H:i:s'), $approvedAt ); } function okr_review_entry(int $id, string $decision, array $managerScores, string $note, array $actor): void { $entry = okr_fetch_entry($id); if (!$entry) { throw new RuntimeException('Objective not found.'); } if (!okr_can_review($entry, $actor)) { throw new RuntimeException('Your current role does not have approval authority for this objective.'); } $keyResults = $entry['key_results']; foreach ($keyResults as $index => &$keyResult) { if (array_key_exists($index, $managerScores)) { $keyResult['manager_score'] = okr_safe_score($managerScores[$index]); } } unset($keyResult); $decision = $decision === 'reject' ? 'reject' : 'approve'; $status = $decision === 'approve' ? 'Approved' : 'Draft'; $approvedAt = $decision === 'approve' ? gmdate('Y-m-d H:i:s') : null; $message = $decision === 'approve' ? 'Approved and scored by ' . ($actor['name'] ?? 'approver') . '.' : 'Returned to draft with feedback from ' . ($actor['name'] ?? 'approver') . '.'; if ($note !== '') { $message .= ' Note: ' . $note; $entry['comments'][] = okr_comment_item($actor, $note); } $entry['activity'][] = okr_activity_item($actor, $message, $decision === 'approve' ? 'approved' : 'rejected'); okr_update_entry_record( $entry, $keyResults, $entry['comments'], $entry['activity'], $status, $entry['submitted_at'] ?: gmdate('Y-m-d H:i:s'), $approvedAt ); } function okr_add_comment(int $id, string $message, array $actor): void { $entry = okr_fetch_entry($id); if (!$entry) { throw new RuntimeException('Objective not found.'); } $message = okr_clean_text($message, 500); if ($message === '') { throw new RuntimeException('Write a short comment before posting.'); } $entry['comments'][] = okr_comment_item($actor, $message); $entry['activity'][] = okr_activity_item($actor, 'Added a comment.', 'comment'); okr_update_entry_record( $entry, $entry['key_results'], $entry['comments'], $entry['activity'], (string) $entry['status'], $entry['submitted_at'] ?: null, $entry['approved_at'] ?: null ); } function okr_delete_entry(int $id, array $actor): void { $entry = okr_fetch_entry($id); if (!$entry) { throw new RuntimeException('Objective not found.'); } if (($entry['status'] ?? '') !== 'Draft') { throw new RuntimeException('Only draft objectives can be deleted in this first MVP slice.'); } if (!okr_can_edit_owner($entry, $actor) && !okr_is_admin($actor)) { throw new RuntimeException('You can only delete your own draft objective.'); } $stmt = db()->prepare('DELETE FROM okr_entries WHERE id = :id'); $stmt->bindValue(':id', $id, PDO::PARAM_INT); $stmt->execute(); } function okr_collect_notifications(array $entries, int $limit = 8): array { $notifications = []; foreach ($entries as $entry) { foreach (($entry['activity'] ?? []) as $activity) { $notifications[] = [ 'time' => $activity['time'] ?? '', 'message' => $activity['message'] ?? '', 'actor_name' => $activity['actor_name'] ?? 'System', 'kind' => $activity['kind'] ?? 'update', 'objective_title' => $entry['objective_title'] ?? '', 'objective_id' => (int) ($entry['id'] ?? 0), ]; } } usort($notifications, static function (array $left, array $right): int { return strcmp((string) ($right['time'] ?? ''), (string) ($left['time'] ?? '')); }); return array_slice($notifications, 0, $limit); } function okr_dashboard_metrics(array $entries): array { $metrics = [ 'total' => count($entries), 'draft' => 0, 'pending' => 0, 'approved' => 0, 'average_score' => 0, 'approval_rate' => 0, 'departments' => [], ]; $scoreTotal = 0.0; foreach ($entries as $entry) { $status = (string) ($entry['status'] ?? 'Draft'); if ($status === 'Draft') { $metrics['draft']++; } elseif ($status === 'Pending') { $metrics['pending']++; } elseif ($status === 'Approved') { $metrics['approved']++; } $scoreTotal += (float) ($entry['objective_score'] ?? 0); $department = (string) ($entry['department_name'] ?? 'Unassigned'); if (!isset($metrics['departments'][$department])) { $metrics['departments'][$department] = [ 'name' => $department, 'count' => 0, 'approved' => 0, 'average_score' => 0, 'score_total' => 0.0, ]; } $metrics['departments'][$department]['count']++; if ($status === 'Approved') { $metrics['departments'][$department]['approved']++; } $metrics['departments'][$department]['score_total'] += (float) ($entry['objective_score'] ?? 0); } if ($metrics['total'] > 0) { $metrics['average_score'] = round($scoreTotal / $metrics['total'], 1); $metrics['approval_rate'] = round(($metrics['approved'] / $metrics['total']) * 100, 1); } foreach ($metrics['departments'] as &$department) { $department['average_score'] = $department['count'] > 0 ? round($department['score_total'] / $department['count'], 1) : 0.0; unset($department['score_total']); } unset($department); uasort($metrics['departments'], static fn(array $a, array $b): int => $b['count'] <=> $a['count']); return $metrics; } function okr_redirect(string $path): never { header('Location: ' . $path); exit; } function okr_time_label(?string $utc): string { if (!$utc) { return '—'; } $time = strtotime($utc . ' UTC'); if ($time === false) { return '—'; } return gmdate('M j, Y · H:i', $time) . ' UTC'; }