exec( 'CREATE TABLE IF NOT EXISTS visit_counter_sessions (' . ' visit_token CHAR(48) NOT NULL,' . ' first_seen_at DATETIME NOT NULL,' . ' last_seen_at DATETIME NOT NULL,' . ' created_at DATETIME NOT NULL,' . ' updated_at DATETIME NOT NULL,' . ' PRIMARY KEY (visit_token),' . ' KEY idx_last_seen_at (last_seen_at)' . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' ); $pdo->exec( 'CREATE TABLE IF NOT EXISTS visit_counter_daily (' . ' visit_token CHAR(48) NOT NULL,' . ' visit_date DATE NOT NULL,' . ' created_at DATETIME NOT NULL,' . ' PRIMARY KEY (visit_token, visit_date),' . ' KEY idx_visit_date (visit_date)' . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' ); $pdo->exec( 'CREATE TABLE IF NOT EXISTS visit_counter_meta (' . ' meta_key VARCHAR(64) NOT NULL,' . ' meta_value BIGINT UNSIGNED NOT NULL DEFAULT 0,' . ' updated_at DATETIME NOT NULL,' . ' PRIMARY KEY (meta_key)' . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' ); $initialized = true; } function visit_counter_prune_stale_data(?DateTimeImmutable $now = null): void { static $pruned = false; if ($pruned) { return; } visit_counter_ensure_schema(); $pdo = db(); $now = $now ?? visit_counter_now(); $sessionCutoff = $now->modify('-' . VISIT_COUNTER_RETENTION_MONTHS . ' months')->format('Y-m-d H:i:s'); $dailyCutoff = $now->modify('-' . VISIT_COUNTER_RETENTION_MONTHS . ' months')->format('Y-m-d'); $deleteDaily = $pdo->prepare('DELETE FROM visit_counter_daily WHERE visit_date < :cutoff'); $deleteDaily->bindValue(':cutoff', $dailyCutoff); $deleteDaily->execute(); $deleteSessions = $pdo->prepare('DELETE FROM visit_counter_sessions WHERE last_seen_at < :cutoff'); $deleteSessions->bindValue(':cutoff', $sessionCutoff); $deleteSessions->execute(); $pruned = true; } function visit_counter_snapshot(?DateTimeImmutable $now = null): array { visit_counter_ensure_schema(); visit_counter_prune_stale_data($now); $pdo = db(); $now = $now ?? visit_counter_now(); $threshold = $now->modify('-' . VISIT_COUNTER_LIVE_WINDOW_MINUTES . ' minutes')->format('Y-m-d H:i:s'); $today = $now->format('Y-m-d'); $liveStmt = $pdo->prepare('SELECT COUNT(*) FROM visit_counter_sessions WHERE last_seen_at >= :threshold'); $liveStmt->bindValue(':threshold', $threshold); $liveStmt->execute(); $live = (int) $liveStmt->fetchColumn(); $dailyStmt = $pdo->prepare('SELECT COUNT(*) FROM visit_counter_daily WHERE visit_date = :visit_date'); $dailyStmt->bindValue(':visit_date', $today); $dailyStmt->execute(); $daily = (int) $dailyStmt->fetchColumn(); $totalStmt = $pdo->prepare('SELECT meta_value FROM visit_counter_meta WHERE meta_key = :meta_key'); $totalStmt->bindValue(':meta_key', VISIT_COUNTER_TOTAL_KEY); $totalStmt->execute(); $totalValue = $totalStmt->fetchColumn(); $total = $totalValue === false ? 0 : (int) $totalValue; if ($total === 0) { $fallbackStmt = $pdo->query('SELECT COUNT(*) FROM visit_counter_sessions'); $total = (int) $fallbackStmt->fetchColumn(); } return [ 'live' => $live, 'daily' => $daily, 'total' => $total, 'live_window_minutes' => VISIT_COUNTER_LIVE_WINDOW_MINUTES, 'updated_at' => $now->format(DATE_ATOM), 'updated_label' => $now->format('H:i:s'), ]; } function visit_counter_increment_total(string $timestamp): void { $stmt = db()->prepare( 'INSERT INTO visit_counter_meta (meta_key, meta_value, updated_at) VALUES (:meta_key, :meta_value, :updated_at) ' . 'ON DUPLICATE KEY UPDATE meta_value = meta_value + 1, updated_at = :updated_at_refresh' ); $stmt->bindValue(':meta_key', VISIT_COUNTER_TOTAL_KEY); $stmt->bindValue(':meta_value', 1, PDO::PARAM_INT); $stmt->bindValue(':updated_at', $timestamp); $stmt->bindValue(':updated_at_refresh', $timestamp); $stmt->execute(); } function visit_counter_decrement_total(string $timestamp): void { $stmt = db()->prepare( 'INSERT INTO visit_counter_meta (meta_key, meta_value, updated_at) VALUES (:meta_key, :meta_value, :updated_at) ' . 'ON DUPLICATE KEY UPDATE meta_value = CASE WHEN meta_value > 0 THEN meta_value - 1 ELSE 0 END, updated_at = :updated_at_refresh' ); $stmt->bindValue(':meta_key', VISIT_COUNTER_TOTAL_KEY); $stmt->bindValue(':meta_value', 0, PDO::PARAM_INT); $stmt->bindValue(':updated_at', $timestamp); $stmt->bindValue(':updated_at_refresh', $timestamp); $stmt->execute(); } function visit_counter_track(?string $token = null): array { visit_counter_ensure_schema(); $pdo = db(); $now = visit_counter_now(); visit_counter_prune_stale_data($now); $visitToken = visit_counter_normalize_token($token) ?? visit_counter_generate_token(); $timestamp = $now->format('Y-m-d H:i:s'); $visitDate = $now->format('Y-m-d'); $pdo->beginTransaction(); try { $insertSession = $pdo->prepare( 'INSERT IGNORE INTO visit_counter_sessions (visit_token, first_seen_at, last_seen_at, created_at, updated_at) ' . 'VALUES (:visit_token, :first_seen_at, :last_seen_at, :created_at, :updated_at)' ); $insertSession->bindValue(':visit_token', $visitToken); $insertSession->bindValue(':first_seen_at', $timestamp); $insertSession->bindValue(':last_seen_at', $timestamp); $insertSession->bindValue(':created_at', $timestamp); $insertSession->bindValue(':updated_at', $timestamp); $insertSession->execute(); $isNewVisitor = $insertSession->rowCount() === 1; if (!$isNewVisitor) { $updateSession = $pdo->prepare( 'UPDATE visit_counter_sessions SET last_seen_at = :last_seen_at, updated_at = :updated_at WHERE visit_token = :visit_token' ); $updateSession->bindValue(':last_seen_at', $timestamp); $updateSession->bindValue(':updated_at', $timestamp); $updateSession->bindValue(':visit_token', $visitToken); $updateSession->execute(); } $insertDaily = $pdo->prepare( 'INSERT IGNORE INTO visit_counter_daily (visit_token, visit_date, created_at) VALUES (:visit_token, :visit_date, :created_at)' ); $insertDaily->bindValue(':visit_token', $visitToken); $insertDaily->bindValue(':visit_date', $visitDate); $insertDaily->bindValue(':created_at', $timestamp); $insertDaily->execute(); if ($isNewVisitor) { visit_counter_increment_total($timestamp); } $pdo->commit(); } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } return [ 'token' => $visitToken, 'counts' => visit_counter_snapshot($now), ]; } function visit_counter_forget(?string $token = null): array { visit_counter_ensure_schema(); $pdo = db(); $now = visit_counter_now(); visit_counter_prune_stale_data($now); $visitToken = visit_counter_normalize_token($token); if ($visitToken === null) { return visit_counter_snapshot($now); } $timestamp = $now->format('Y-m-d H:i:s'); $pdo->beginTransaction(); try { $deleteDaily = $pdo->prepare('DELETE FROM visit_counter_daily WHERE visit_token = :visit_token'); $deleteDaily->bindValue(':visit_token', $visitToken); $deleteDaily->execute(); $deleteSession = $pdo->prepare('DELETE FROM visit_counter_sessions WHERE visit_token = :visit_token'); $deleteSession->bindValue(':visit_token', $visitToken); $deleteSession->execute(); if ($deleteSession->rowCount() > 0) { visit_counter_decrement_total($timestamp); } $pdo->commit(); } catch (Throwable $exception) { if ($pdo->inTransaction()) { $pdo->rollBack(); } throw $exception; } return visit_counter_snapshot($now); }