TMDO_ARCHIVE_DAYS ago. * - Archives hp_price, hp_featured, hp_verified, hp_expired_time with gzip compression. */ class TMDO_Listing_Stats { /** Meta key used for the warm-zone view counter. */ const VIEW_KEY = 'wpdo_views'; /** TTL for warm-zone view entries (24 hours). */ const VIEW_TTL = DAY_IN_SECONDS; /** How many days after expiry before archiving hot fields. */ const ARCHIVE_DAYS = 30; /** Hot-zone meta keys to archive for expired listings. */ const ARCHIVE_KEYS = array( 'hp_price', 'hp_featured', 'hp_verified', 'hp_expired_time' ); /** * Register all hooks. */ public static function register_hooks(): void { // Zone B: increment view count on front-end singular listing pages. add_action( 'wp', array( __CLASS__, 'maybe_increment_view' ) ); // Zone B: flush accumulated warm-zone counts to postmeta (hourly). add_action( 'wpdo_flush_views', array( __CLASS__, 'flush_views_to_postmeta' ) ); // Zone D: archive expired listing fields (daily cron via wpdo_archive_sweep). add_action( 'wpdo_archive_sweep', array( __CLASS__, 'archive_expired_listings' ), 20 ); } /** * Schedule the flush cron if not already registered. * * Called from TMDO_Core::schedule_cron(). */ public static function schedule_cron(): void { if ( ! wp_next_scheduled( 'wpdo_flush_views' ) ) { wp_schedule_event( time(), 'hourly', 'wpdo_flush_views' ); } } // ── Zone B: View Counter ───────────────────────────────────────────────── /** * Increment the warm-zone view counter for the current listing page. * * Only fires on front-end, singular hp_listing pages, for non-bot requests. */ public static function maybe_increment_view(): void { if ( is_admin() || ! is_singular( 'hp_listing' ) ) { return; } // Skip common bots by checking for empty or known bot user-agents. $ua = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ); if ( empty( $ua ) || preg_match( '/bot|crawl|slurp|spider|mediapartners/i', $ua ) ) { return; } $post_id = (int) get_the_ID(); if ( $post_id <= 0 ) { return; } self::increment_view( $post_id ); } /** * Increment the warm-zone view count for a listing. * * @param int $post_id Listing post ID. */ public static function increment_view( int $post_id ): void { $current = (int) TMDO_Zone_Warm::get( $post_id, self::VIEW_KEY ); TMDO_Zone_Warm::set( $post_id, self::VIEW_KEY, (string) ( $current + 1 ), self::VIEW_TTL ); } /** * Read the view count for a listing. * * Returns warm-zone value if available; falls back to postmeta hp_view_count. * * @param int $post_id Listing post ID. * @return int */ public static function get_view_count( int $post_id ): int { $warm = TMDO_Zone_Warm::get( $post_id, self::VIEW_KEY ); if ( null !== $warm ) { return (int) $warm; } return (int) get_post_meta( $post_id, 'hp_view_count', true ); } /** * Flush all warm-zone view counts to postmeta and reset warm entries. * * Called hourly by wpdo_flush_views cron. * * @return int Number of listings flushed. */ public static function flush_views_to_postmeta(): int { global $wpdb; $table = TMDO_Zone_Warm::table(); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, meta_value FROM `{$table}` WHERE meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared self::VIEW_KEY ), ARRAY_A ); if ( ! $rows ) { return 0; } // Batch-fetch existing hp_view_count for all post IDs in one query. $post_ids = array_map( 'intval', array_column( $rows, 'post_id' ) ); $placeholders = implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ); $existing_rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = 'hp_view_count' AND post_id IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $wpdb->postmeta is core. phpcs:ignore WPDO.AntiEAV.no-direct-postmeta-select -- Listing Stats: Zone B → postmeta view-count sync path. ...$post_ids ), ARRAY_A ); $existing_map = array(); foreach ( $existing_rows as $er ) { $existing_map[ (int) $er['post_id'] ] = (int) $er['meta_value']; } $flushed = 0; foreach ( $rows as $row ) { $post_id = (int) $row['post_id']; $new_views = (int) $row['meta_value']; // Add to existing postmeta total. $existing = $existing_map[ $post_id ] ?? 0; update_post_meta( $post_id, 'hp_view_count', $existing + $new_views ); // Remove the warm entry after flush. TMDO_Zone_Warm::delete( $post_id, self::VIEW_KEY ); ++$flushed; } return $flushed; } // ── Zone D: Expired Listing Archival ───────────────────────────────────── /** * Archive hot-zone fields for listings that expired ARCHIVE_DAYS ago. * * Reads hp_expired_time from postmeta (or hot zone via Sync Bridge). * Archives ARCHIVE_KEYS with gzip compression into wpdo_archive. * * @param int $limit Max listings to process per run. * @return int Number of listing fields archived. */ public static function archive_expired_listings( int $limit = 200 ): int { global $wpdb; $cutoff = (int) ( time() - self::ARCHIVE_DAYS * DAY_IN_SECONDS ); // Find published listings with hp_expired_time before cutoff. $listings = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT pm.post_id FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = 'hp_expired_time' AND pm.meta_value > '0' AND CAST(pm.meta_value AS UNSIGNED) < %d AND p.post_type = 'hp_listing' AND p.post_status IN ('publish','private') LIMIT %d", $cutoff, $limit ) ); if ( ! $listings ) { return 0; } $listing_ids = array_map( 'intval', $listings ); $id_list = implode( ',', $listing_ids ); $key_list = implode( ',', array_fill( 0, count( self::ARCHIVE_KEYS ), '%s' ) ); // Batch-fetch all relevant postmeta rows in one query. $meta_rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_id, post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id IN ({$id_list}) AND meta_key IN ({$key_list})", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $wpdb->postmeta is core. phpcs:ignore WPDO.AntiEAV.no-direct-postmeta-select -- Archive sweep: must read postmeta to migrate to Zone D. ...self::ARCHIVE_KEYS ), ARRAY_A ); // Build lookup: $meta_map[ post_id ][ meta_key ] = [ meta_id, meta_value ]. $meta_map = array(); foreach ( $meta_rows as $mr ) { $meta_map[ (int) $mr['post_id'] ][ $mr['meta_key'] ] = array( 'meta_id' => (int) $mr['meta_id'], 'meta_value' => $mr['meta_value'], ); } $archived = 0; foreach ( $listing_ids as $post_id ) { foreach ( self::ARCHIVE_KEYS as $meta_key ) { if ( ! isset( $meta_map[ $post_id ][ $meta_key ] ) ) { continue; } $entry = $meta_map[ $post_id ][ $meta_key ]; $meta_value = $entry['meta_value']; if ( '' === $meta_value || false === $meta_value ) { continue; } TMDO_Zone_Archive::archive( $post_id, 'hp_listing', $meta_key, (string) $meta_value, $entry['meta_id'], true // compress. ); ++$archived; } } return $archived; } /** * Get archive statistics for expired listings. * * @return array{total_rows: int, compressed_rows: int, post_types: array} */ public static function stats(): array { return TMDO_Zone_Archive::stats(); } }