campaigns->all(), static fn( Campaign $c ) => ( ! $campaign || $c->id === $campaign ) && ( ! $category || ( -1 === $category ? ! $c->category_id : (int) $c->category_id === $category ) ) ); return $this->report( $days[0], $days[1], array_values( $campaigns ), $this->categories->all() ); } /** * Mark the figures as changed, so cached reports are not reused. */ public static function changed(): void { wp_cache_set( 'last_changed', microtime(), self::CACHE_GROUP ); } /** * First and last day (site time, Y-m-d) of a period: week = 7, month = 30 and 3 months = 90 days up to today. * * @param string $period One of PERIODS. * @param string $from Custom start, Y-m-d. * @param string $to Custom end, Y-m-d. * @param string $today Today in site time, Y-m-d. * @return array{0: string, 1: string}|null Null for an invalid custom period. */ public static function period( string $period, string $from, string $to, string $today ): ?array { $end = new \DateTimeImmutable( $today ); switch ( $period ) { case 'week': return array( $end->modify( '-6 days' )->format( 'Y-m-d' ), $today ); case '3months': return array( $end->modify( '-89 days' )->format( 'Y-m-d' ), $today ); case 'custom': if ( ! self::is_date( $from ) || ! self::is_date( $to ) || $from > $to ) { return null; } $days = (int) ( new \DateTimeImmutable( $from ) )->diff( new \DateTimeImmutable( $to ) )->days + 1; return $days > self::MAX_DAYS ? null : array( $from, $to ); default: return array( $end->modify( '-29 days' )->format( 'Y-m-d' ), $today ); } } /** * Up to 62 days the chart shows days, after that weeks starting on Monday. * * @param string $from First day, Y-m-d. * @param string $to Last day, Y-m-d. * @return array{bucket: string, keys: string[]} */ public static function buckets( string $from, string $to ): array { $start = new \DateTimeImmutable( $from ); $end = new \DateTimeImmutable( $to ); $days = (int) $start->diff( $end )->days + 1; $week = $days > 62; $keys = array(); for ( $day = $week ? self::monday( $start ) : $start; $day <= $end; $day = $day->modify( $week ? '+7 days' : '+1 day' ) ) { $keys[] = $day->format( 'Y-m-d' ); } return array( 'bucket' => $week ? 'week' : 'day', 'keys' => $keys, ); } /** * The report for a period. $campaigns are the campaigns the filter allows. * * @param string $from First day, Y-m-d (site time). * @param string $to Last day, Y-m-d (site time). * @param Campaign[] $campaigns Campaigns to include. * @param array $categories Category ID => name. */ public function report( string $from, string $to, array $campaigns, array $categories ): array { $key = md5( wp_json_encode( array( $from, $to, array_map( static fn( Campaign $c ) => $c->id, $campaigns ) ) ) ) . ':' . wp_cache_get( 'last_changed', self::CACHE_GROUP ); $cached = wp_cache_get( 'report:' . $key, self::CACHE_GROUP ); if ( is_array( $cached ) ) { $rows = $cached; } else { $rows = $this->query( self::utc( $from . ' 00:00:00' ), self::utc( $to . ' 23:59:59' ) ); wp_cache_set( 'report:' . $key, $rows, self::CACHE_GROUP, 5 * MINUTE_IN_SECONDS ); } return self::build( $from, $to, $campaigns, $categories, $rows['issued'], $rows['hours'] ); } /** * Put the queried rows together. Kept free of the database so it can be tested. * * @param string $from First day, Y-m-d. * @param string $to Last day, Y-m-d. * @param Campaign[] $campaigns Campaigns to include. * @param array $categories Category ID => name. * @param array $issued Rows: campaign_id, issued, used (personal codes issued in the period). * @param array $hours Rows: campaign_id, hour (UTC, Y-m-d H:00:00), redemptions, saved, revenue. */ public static function build( string $from, string $to, array $campaigns, array $categories, array $issued, array $hours ): array { $buckets = self::buckets( $from, $to ); $series = array_fill_keys( $buckets['keys'], array( 'redemptions' => 0, 'saved' => 0.0, ) ); $rows = array(); foreach ( $campaigns as $campaign ) { $rows[ $campaign->id ] = array( 'id' => $campaign->id, 'name' => $campaign->name, 'category_id' => (int) $campaign->category_id, 'type' => $campaign->type, 'status' => $campaign->status, ) + self::zero(); } foreach ( $issued as $row ) { $id = (int) $row->campaign_id; if ( isset( $rows[ $id ] ) ) { $rows[ $id ]['issued'] = (int) $row->issued; $rows[ $id ]['used'] = (int) $row->used; } } $zone = wp_timezone(); $utc = new \DateTimeZone( 'UTC' ); foreach ( $hours as $row ) { $id = (int) $row->campaign_id; if ( ! isset( $rows[ $id ] ) ) { continue; } $rows[ $id ]['redemptions'] += (int) $row->redemptions; $rows[ $id ]['saved'] += (float) $row->saved; $rows[ $id ]['revenue'] += (float) $row->revenue; $day = ( new \DateTimeImmutable( (string) $row->hour, $utc ) )->setTimezone( $zone ); $bucket = ( 'week' === $buckets['bucket'] ? self::monday( $day ) : $day )->format( 'Y-m-d' ); if ( isset( $series[ $bucket ] ) ) { $series[ $bucket ]['redemptions'] += (int) $row->redemptions; $series[ $bucket ]['saved'] += (float) $row->saved; } } // Campaigns with nothing in the period are left out unless they are running. $rows = array_values( array_filter( array_map( array( self::class, 'finish' ), $rows ), static fn( array $row ) => 'active' === $row['status'] || $row['issued'] || $row['redemptions'] ) ); $groups = array(); foreach ( $rows as $row ) { $id = isset( $categories[ $row['category_id'] ] ) ? $row['category_id'] : 0; if ( ! isset( $groups[ $id ] ) ) { $groups[ $id ] = array( 'id' => $id, 'name' => $categories[ $id ] ?? '', ) + self::zero(); } foreach ( array_keys( self::zero() ) as $field ) { $groups[ $id ][ $field ] += $row[ $field ]; } } $totals = self::zero(); $shared = count( array_filter( $rows, static fn( array $row ) => Campaign::TYPE_SHARED === $row['type'] ) ); foreach ( $rows as $row ) { foreach ( array_keys( $totals ) as $field ) { $totals[ $field ] += $row[ $field ]; } } $points = array(); foreach ( $series as $date => $point ) { $points[] = array( 'date' => $date, 'redemptions' => $point['redemptions'], 'saved' => round( $point['saved'], 2 ), ); } return array( 'from' => $from, 'to' => $to, 'bucket' => $buckets['bucket'], 'totals' => self::finish( $totals ) + array( 'shared_campaigns' => $shared ), 'series' => $points, 'categories' => array_values( array_map( array( self::class, 'finish' ), $groups ) ), 'campaigns' => $rows, ); } /** * @param string $from UTC start, Y-m-d H:i:s. * @param string $to UTC end, Y-m-d H:i:s. * @return array{issued: array, hours: array} */ private function query( string $from, string $to ): array { global $wpdb; $issued = (array) $wpdb->get_results( $wpdb->prepare( "SELECT campaign_id, COUNT(*) AS issued, SUM( uses > 0 ) AS used FROM %i WHERE email <> '' AND created_at BETWEEN %s AND %s GROUP BY campaign_id", Tables::codes(), $from, $to ) ); // Grouped per UTC hour, so PHP can place each hour on the right local day across DST changes. $hours = (array) $wpdb->get_results( $wpdb->prepare( "SELECT campaign_id, DATE_FORMAT( redeemed_at, '%%Y-%%m-%%d %%H:00:00' ) AS hour, COUNT(*) AS redemptions, SUM( discount_amount ) AS saved, SUM( order_total ) AS revenue FROM %i WHERE released_at IS NULL AND redeemed_at BETWEEN %s AND %s GROUP BY campaign_id, hour", Tables::redemptions(), $from, $to ) ); return array( 'issued' => $issued, 'hours' => $hours, ); } /** * @param array $row Figures. */ private static function finish( array $row ): array { $row['saved'] = round( $row['saved'], 2 ); $row['revenue'] = round( $row['revenue'], 2 ); $row['rate'] = $row['issued'] ? round( 100 * $row['used'] / $row['issued'], 1 ) : null; return $row; } private static function zero(): array { return array( 'issued' => 0, 'used' => 0, 'redemptions' => 0, 'saved' => 0.0, 'revenue' => 0.0, ); } /** * @param string $local Site time, Y-m-d H:i:s. */ private static function utc( string $local ): string { return ( new \DateTimeImmutable( $local, wp_timezone() ) )->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ); } private static function monday( \DateTimeImmutable $day ): \DateTimeImmutable { return $day->modify( '-' . ( (int) $day->format( 'N' ) - 1 ) . ' days' ); } private static function is_date( string $date ): bool { $parsed = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date ); return $parsed && $parsed->format( 'Y-m-d' ) === $date; } }