array( 'post_published', 'page_updated', 'post_updated' ), 'maintenance' => array( 'plugin_updated', 'theme_updated', 'core_updated', 'server_updated', 'plugin_installed', 'plugin_removed', 'theme_installed', 'theme_removed' ), 'backups' => array( 'backup_completed' ), 'site' => array( 'template_updated', 'menu_updated', 'design_updated' ), 'leads' => array( 'form_submitted' ), ); /** The maintenance events that count as software updates. */ const UPDATE_TYPES = array( 'plugin_updated', 'theme_updated', 'core_updated', 'server_updated' ); /** * The report's branding and options, with defaults. * * @return array{agency_name: string, logo_id: int, color: string, lead_names: bool, credit: bool} */ public static function settings() { $saved = get_option( self::OPTION, array() ); return wp_parse_args( is_array( $saved ) ? $saved : array(), array( 'agency_name' => '', 'logo_id' => 0, 'color' => '#4f46e5', 'lead_names' => false, 'credit' => false, ) ); } /** * When the plugin started recording on this site. Set on activation; for sites that had * the plugin before that, the oldest event in the log. * * @return int Unix time. */ public static function recording_since() { $since = (int) get_option( self::SINCE, 0 ); if ( ! $since ) { $first = Monthwrap_Store::first_at(); $since = $first ? $first : time(); add_option( self::SINCE, $since, '', false ); } return $since; } /** * The start and end of a calendar month in the site's time zone. * * @param string $month "YYYY-MM". * @return DateTimeImmutable[] Start (inclusive) and end (exclusive). */ public static function month_range( $month ) { $start = DateTimeImmutable::createFromFormat( '!Y-m', $month, wp_timezone() ); if ( ! $start ) { $start = new DateTimeImmutable( 'first day of this month midnight', wp_timezone() ); } return array( $start, $start->modify( '+1 month' ) ); } /** * The months there's a report for, newest first, as "YYYY-MM" => "October 2026": this * month back to the month recording started, at most twelve. * * @return array */ public static function recent_months() { $months = array(); $first = wp_date( 'Y-m', self::recording_since() ); $month = new DateTimeImmutable( 'first day of this month midnight', wp_timezone() ); for ( $i = 0; $i < 12 && ( 0 === $i || $month->format( 'Y-m' ) >= $first ); $i++ ) { $months[ $month->format( 'Y-m' ) ] = wp_date( 'F Y', $month->getTimestamp() ); $month = $month->modify( '-1 month' ); } return $months; } /** * How much content, how many updates and how many leads a month had, for the list of * reports. * * @param string $month "YYYY-MM". * @return array{content: int, updates: int, leads: int} */ public static function month_counts( $month ) { list( $start, $end ) = self::month_range( $month ); $from = gmdate( 'Y-m-d H:i:s', $start->getTimestamp() ); $to = gmdate( 'Y-m-d H:i:s', $end->getTimestamp() ); return array( 'content' => Monthwrap_Store::count_between( $from, $to, self::SECTIONS['content'] ), 'updates' => Monthwrap_Store::count_between( $from, $to, self::UPDATE_TYPES ), 'leads' => Monthwrap_Store::count_between( $from, $to, self::SECTIONS['leads'] ), ); } /** * Everything a report shows. * * @param DateTimeImmutable $start Start of the period (inclusive). * @param DateTimeImmutable $end End of the period (exclusive). * @return array */ public static function build( DateTimeImmutable $start, DateTimeImmutable $end ) { $from = gmdate( 'Y-m-d H:i:s', $start->getTimestamp() ); $to = gmdate( 'Y-m-d H:i:s', $end->getTimestamp() ); $settings = self::settings(); $sections = array(); foreach ( self::SECTIONS as $section => $types ) { $sections[ $section ] = array(); foreach ( array_reverse( Monthwrap_Store::between( $from, $to, $types, 2000 ) ) as $row ) { $sections[ $section ][] = $row['event']; } } $leads_by_form = array(); foreach ( $sections['leads'] as $lead ) { $form = '' !== (string) ( $lead['form_name'] ?? '' ) ? (string) $lead['form_name'] : __( 'Other forms', 'monthwrap' ); $leads_by_form[ $form ] = ( $leads_by_form[ $form ] ?? 0 ) + 1; } arsort( $leads_by_form ); $updates = array_values( array_filter( $sections['maintenance'], function ( $event ) { return in_array( $event['type'], self::UPDATE_TYPES, true ); } ) ); $backups = self::backups( $sections['backups'] ); $published = count( wp_list_filter( $sections['content'], array( 'type' => 'post_published' ) ) ); $now = time(); $report = array( 'site' => array( 'name' => html_entity_decode( get_bloginfo( 'name' ), ENT_QUOTES, 'UTF-8' ), 'url' => home_url( '/' ), ), 'period' => array( 'label' => self::period_label( $start, $end ), 'month' => wp_date( 'F', $start->getTimestamp() ), 'start' => $start, 'end' => $end, 'current' => $start->getTimestamp() <= $now && $now < $end->getTimestamp(), 'covers' => self::covers( $start->getTimestamp(), min( $now, $end->getTimestamp() - 1 ) ), ), 'generated' => wp_date( get_option( 'date_format' ) ), 'settings' => $settings, 'totals' => array( 'published' => $published, 'updated' => count( $sections['content'] ) - $published, 'updates' => count( $updates ), 'backups' => $backups['ok'], 'site' => count( $sections['site'] ), 'leads' => count( $sections['leads'] ), ), 'sections' => $sections, 'updates' => $updates, 'update_parts' => self::update_parts( $updates ), 'backups' => $backups, 'leads_by_form' => $leads_by_form, 'status' => self::site_status(), ); $report['summary'] = self::summary( $report ); $report['notes'] = self::notes( $report ); return $report; } /** * A line under each of the four numbers: "7 published, 4 updated", "6 plugins, 1 theme, * WordPress, PHP", "1 failed, on 13 October", "10 from Contact us, 4 from Free quote". * * @param array $report The report so far. * @return array{content: string, updates: string, backups: string, leads: string} */ private static function notes( array $report ) { $totals = $report['totals']; $backups = $report['backups']; if ( $backups['failed'] ) { $when = self::day( strtotime( (string) $backups['last_failed']['occurred_at'] ) ); $backup = 1 === $backups['failed'] /* translators: %s: a date, e.g. 13 October. */ ? sprintf( __( '1 failed, on %s', 'monthwrap' ), $when ) /* translators: 1: number of failed backups, 2: a date, e.g. 13 October. */ : sprintf( __( '%1$s failed, the latest on %2$s', 'monthwrap' ), number_format_i18n( $backups['failed'] ), $when ); } elseif ( $backups['last_ok'] ) { /* translators: %s: a date, e.g. 25 October. */ $backup = sprintf( __( 'Latest on %s', 'monthwrap' ), self::day( strtotime( (string) $backups['last_ok']['occurred_at'] ) ) ); } else { $backup = __( 'None recorded', 'monthwrap' ); } $leads = array(); foreach ( array_slice( $report['leads_by_form'], 0, 2, true ) as $form => $count ) { /* translators: 1: number of leads, 2: form name. */ $leads[] = sprintf( __( '%1$s from %2$s', 'monthwrap' ), number_format_i18n( $count ), $form ); } $others = array_sum( array_slice( $report['leads_by_form'], 2 ) ); if ( $others ) { /* translators: %s: number of leads. */ $leads[] = sprintf( __( '%s from other forms', 'monthwrap' ), number_format_i18n( $others ) ); } return array( /* translators: 1: posts and pages published, 2: posts and pages updated. */ 'content' => sprintf( __( '%1$s published, %2$s updated', 'monthwrap' ), number_format_i18n( $totals['published'] ), number_format_i18n( $totals['updated'] ) ), 'updates' => $report['update_parts'] ? implode( ', ', $report['update_parts'] ) : __( 'None needed', 'monthwrap' ), 'backups' => $backup, 'leads' => $leads ? implode( ', ', $leads ) : __( 'No form submissions', 'monthwrap' ), ); } /** * A day, written the way the site writes dates: "24 October" or "October 24". * * @param int $timestamp Unix time. * @param string $style short ("24 Oct"), long ("24 October"), year ("24 October 2026") * or weekday ("Saturday 24 October"). * @return string */ public static function day( $timestamp, $style = 'long' ) { $formats = self::month_first() ? array( 'short' => 'M j', 'long' => 'F j', 'year' => 'F j, Y', 'weekday' => 'l, F j', ) : array( 'short' => 'j M', 'long' => 'j F', 'year' => 'j F Y', 'weekday' => 'l j F', ); return wp_date( $formats[ $style ] ?? $formats['long'], $timestamp ); } /** * Whether the site's date format puts the month before the day, as in the US. * * @return bool */ private static function month_first() { $format = (string) get_option( 'date_format' ); return strcspn( $format, 'FMmn' ) < strcspn( $format, 'dj' ); } /** * The days a report covers: "1 to 25 October 2026" or "October 1 to 25, 2026". * * @param int $first Unix time on the first day. * @param int $last Unix time on the last day. * @return string */ private static function covers( $first, $last ) { if ( wp_date( 'Y-m-d', $first ) === wp_date( 'Y-m-d', $last ) ) { return self::day( $first, 'year' ); } if ( wp_date( 'Y-m', $first ) !== wp_date( 'Y-m', $last ) ) { /* translators: 1: first day, 2: last day. */ return sprintf( __( '%1$s to %2$s', 'monthwrap' ), self::day( $first, 'year' ), self::day( $last, 'year' ) ); } return self::month_first() /* translators: 1: first day, 2: last day. */ ? sprintf( __( '%1$s to %2$s', 'monthwrap' ), self::day( $first ), wp_date( 'j, Y', $last ) ) /* translators: 1: first day, 2: last day. */ : sprintf( __( '%1$s to %2$s', 'monthwrap' ), wp_date( 'j', $first ), self::day( $last, 'year' ) ); } /** * "October 2026" for a whole month, else "1 Oct 2026 – 15 Oct 2026". * * @param DateTimeImmutable $start Start (inclusive). * @param DateTimeImmutable $end End (exclusive). * @return string */ private static function period_label( DateTimeImmutable $start, DateTimeImmutable $end ) { if ( '01' === $start->format( 'd' ) && $start->modify( '+1 month' )->getTimestamp() === $end->getTimestamp() ) { return wp_date( 'F Y', $start->getTimestamp() ); } $format = get_option( 'date_format' ); return wp_date( $format, $start->getTimestamp() ) . ' – ' . wp_date( $format, $end->modify( '-1 day' )->getTimestamp() ); } /** * Backups in a period: how many worked and failed, the result for each day, and the * tools that made them. * * @param array[] $events Backup events, oldest first. * @return array */ private static function backups( array $events ) { $backups = array( 'ok' => 0, 'failed' => 0, 'last_ok' => null, 'last_failed' => null, 'since_ok' => false, 'days' => array(), 'tools' => array(), ); foreach ( $events as $event ) { $day = wp_date( 'Y-m-d', strtotime( (string) ( $event['occurred_at'] ?? '' ) ) ); $tool = (string) ( $event['name'] ?? '' ); if ( '' !== $tool && ! in_array( $tool, $backups['tools'], true ) ) { $backups['tools'][] = $tool; } if ( 'failed' === ( $event['status'] ?? '' ) ) { ++$backups['failed']; $backups['last_failed'] = $event; $backups['since_ok'] = false; if ( ! isset( $backups['days'][ $day ] ) ) { $backups['days'][ $day ] = 'failed'; } } else { ++$backups['ok']; $backups['last_ok'] = $event; $backups['since_ok'] = true; $backups['days'][ $day ] = 'ok'; } } return $backups; } /** * What was updated, in words: "6 plugins", "1 theme", "WordPress", "PHP". * * @param array[] $updates Update events. * @return string[] */ private static function update_parts( array $updates ) { $plugins = 0; $themes = 0; $core = false; $server = array(); foreach ( $updates as $event ) { if ( 'plugin_updated' === $event['type'] ) { ++$plugins; } elseif ( 'theme_updated' === $event['type'] ) { ++$themes; } elseif ( 'core_updated' === $event['type'] ) { $core = true; } elseif ( '' !== (string) ( $event['name'] ?? '' ) ) { $server[ (string) $event['name'] ] = true; } } $parts = array(); if ( $plugins ) { /* translators: %s: number of plugins. */ $parts[] = sprintf( _n( '%s plugin', '%s plugins', $plugins, 'monthwrap' ), number_format_i18n( $plugins ) ); } if ( $themes ) { /* translators: %s: number of themes. */ $parts[] = sprintf( _n( '%s theme', '%s themes', $themes, 'monthwrap' ), number_format_i18n( $themes ) ); } if ( $core ) { $parts[] = __( 'WordPress', 'monthwrap' ); } return array_merge( $parts, array_keys( $server ) ); } /** * The report in a sentence or two, from its numbers: "So far this month we published 7 * new posts and pages, ... Your forms brought in 14 leads." * * @param array $report The report so far. * @return string */ private static function summary( array $report ) { $totals = $report['totals']; $backups = $report['backups']; $did = array(); if ( $totals['published'] ) { /* translators: %s: number of posts and pages. */ $did[] = sprintf( _n( 'published %s new post or page', 'published %s new posts and pages', $totals['published'], 'monthwrap' ), number_format_i18n( $totals['published'] ) ); } if ( $totals['updated'] ) { /* translators: %s: number of posts and pages. */ $did[] = sprintf( _n( 'updated %s existing post or page', 'updated %s existing posts and pages', $totals['updated'], 'monthwrap' ), number_format_i18n( $totals['updated'] ) ); } if ( $report['update_parts'] ) { /* translators: %s: what was updated, e.g. "6 plugins, 1 theme, WordPress and PHP". */ $did[] = sprintf( __( 'kept %s up to date', 'monthwrap' ), wp_sprintf( '%l', $report['update_parts'] ) ); } if ( $backups['ok'] && $backups['failed'] ) { /* translators: 1: backups that worked, 2: all backups. */ $did[] = sprintf( _n( 'backed up the site %1$s time out of %2$s', 'backed up the site %1$s times out of %2$s', $backups['ok'], 'monthwrap' ), number_format_i18n( $backups['ok'] ), number_format_i18n( $backups['ok'] + $backups['failed'] ) ); } elseif ( $backups['ok'] ) { /* translators: %s: number of backups. */ $did[] = sprintf( _n( 'backed up the site %s time', 'backed up the site %s times', $backups['ok'], 'monthwrap' ), number_format_i18n( $backups['ok'] ) ); } $text = ''; if ( $did && $report['period']['current'] ) { /* translators: %s: a list of what was done, e.g. "published 7 new posts and pages and kept 6 plugins up to date". */ $text = sprintf( __( 'So far this month we %s.', 'monthwrap' ), wp_sprintf( '%l', $did ) ); } elseif ( $did ) { /* translators: 1: month, 2: a list of what was done, e.g. "published 7 new posts and pages and kept 6 plugins up to date". */ $text = sprintf( __( 'In %1$s we %2$s.', 'monthwrap' ), $report['period']['month'], wp_sprintf( '%l', $did ) ); } if ( $totals['leads'] ) { /* translators: %s: number of leads. */ $text .= ( '' !== $text ? ' ' : '' ) . sprintf( _n( 'Your forms brought in %s lead.', 'Your forms brought in %s leads.', $totals['leads'], 'monthwrap' ), number_format_i18n( $totals['leads'] ) ); } if ( '' === $text ) { $text = $report['period']['current'] ? __( 'Nothing has been recorded this month yet.', 'monthwrap' ) : __( 'Nothing was recorded in this period.', 'monthwrap' ); } return $text; } /** * The site's state now: versions, updates waiting, Site Health and the last backup. * * @return array */ public static function site_status() { global $wpdb; if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $plugins = get_plugins(); $waiting = array(); $updates = get_site_transient( 'update_plugins' ); foreach ( (array) ( $updates->response ?? array() ) as $file => $update ) { if ( isset( $plugins[ $file ] ) ) { $waiting[] = trim( $plugins[ $file ]['Name'] . ' ' . ( $update->new_version ?? '' ) ); } } $updates = get_site_transient( 'update_themes' ); foreach ( (array) ( $updates->response ?? array() ) as $slug => $update ) { $theme = wp_get_theme( $slug ); /* translators: 1: theme name, 2: new version. */ $waiting[] = sprintf( __( '%1$s theme %2$s', 'monthwrap' ), $theme->get( 'Name' ), $update['new_version'] ?? '' ); } $core = get_site_transient( 'update_core' ); foreach ( (array) ( $core->updates ?? array() ) as $offer ) { if ( 'upgrade' === ( $offer->response ?? '' ) ) { /* translators: %s: WordPress version. */ $waiting[] = sprintf( __( 'WordPress %s', 'monthwrap' ), $offer->current ?? '' ); break; } } $health = json_decode( (string) get_transient( 'health-check-site-status-result' ), true ); $backup = Monthwrap_Store::between( '1970-01-01 00:00:00', gmdate( 'Y-m-d H:i:s', time() + 60 ), array( 'backup_completed' ), 1 ); return array( 'wordpress' => get_bloginfo( 'version' ), 'php' => PHP_VERSION, 'database' => ( false !== stripos( (string) $wpdb->db_server_info(), 'mariadb' ) ? 'MariaDB ' : 'MySQL ' ) . $wpdb->db_version(), 'theme' => wp_get_theme()->get( 'Name' ), 'plugins' => count( (array) get_option( 'active_plugins', array() ) ), 'waiting' => $waiting, 'health' => is_array( $health ) ? $health : null, 'last_backup' => $backup ? $backup[0]['event'] : null, ); } }