transport = $transport; } /** * Whether a requested window is one this screen offers. * * @param string $range Submitted value. * @return string The range, or the default when it is not one of ours. */ public static function range( string $range ): string { return in_array( $range, self::RANGES, true ) ? $range : self::DEFAULT_RANGE; } /** * Human name of a window. * * @param string $range Range key. * @return string */ public static function range_label( string $range ): string { $labels = array( 'last_7_days' => __( 'Last 7 days', 'adpix-analytics' ), 'last_28_days' => __( 'Last 28 days', 'adpix-analytics' ), 'last_90_days' => __( 'Last 90 days', 'adpix-analytics' ), 'this_month' => __( 'This month', 'adpix-analytics' ), 'last_month' => __( 'Last month', 'adpix-analytics' ), ); return $labels[ $range ] ?? $labels[ self::DEFAULT_RANGE ]; } /** * Top-line totals for the window. * * @param string $range Range key. * @return Report */ public function overview( string $range ): Report { return $this->run( 'overview', $range, array() ); } /** * A daily series, with the days that saw no traffic filled back in. * * @param string $range Range key. * @return Report */ public function timeseries( string $range ): Report { $report = $this->run( 'timeseries', $range, array( 'metrics' => array( 'sessions', 'totalUsers', 'pageviews' ), 'limit' => 400, ) ); return $report->ok() ? $this->fill_days( $report ) : $report; } /** * One breakdown table: the rows are whatever the function's primary dimension is. * * @param string $function Report function, e.g. `geo`, `channels`, `top_pages`, `devices`. * @param string $range Range key. * @param string[] $metrics Metrics to ask for. * @return Report */ public function breakdown( string $function, string $range, array $metrics ): Report { $report = $this->run( $function, $range, array( 'metrics' => array_values( $metrics ), 'limit' => self::ROWS, ) ); return $report->ok() ? $this->sort_by_first_metric( $report ) : $report; } /** * The properties the token can read, for explaining a "property not found". * * The API answers 404 rather than 403 for a property outside the token's reach, so the error alone * cannot say whether the ID is mistyped or the token belongs to another account. This list can. * Not cached: it is only asked on a screen that is already failing. * * @return array{ok:bool,error:string,properties:array} */ public function accessible_properties(): array { $response = $this->transport->properties(); if ( ! $response->is_ok() ) { return array( 'ok' => false, 'error' => $response->error(), 'properties' => array(), ); } $list = array(); $rows = $response->get( 'properties', array() ); foreach ( is_array( $rows ) ? $rows : array() as $row ) { if ( is_array( $row ) && isset( $row['id'] ) && is_scalar( $row['id'] ) ) { $list[] = array( 'id' => (string) $row['id'], 'name' => isset( $row['name'] ) && is_scalar( $row['name'] ) ? (string) $row['name'] : '', ); } } return array( 'ok' => true, 'error' => '', 'properties' => $list, ); } /** * Discard every cached answer. * * Done by moving the generation rather than by deleting rows: transient names cannot be * enumerated without scanning the options table, and a report screen is not worth a table scan. * The orphaned entries expire on their own within ten minutes. * * @return void */ public function flush(): void { update_option( self::GENERATION, (string) time(), false ); } /** * Run one report, through the cache. * * @param string $function Report function name. * @param string $range Range key. * @param array $body Extra request body. * @return Report */ private function run( string $function, string $range, array $body ): Report { $body['date_range'] = array( 'preset' => self::range( $range ) ); $key = $this->cache_key( $function, $body ); $cached = get_transient( $key ); if ( is_array( $cached ) ) { return isset( $cached['error'] ) && '' !== $cached['error'] ? Report::failed( (string) $cached['error'] ) : Report::from_body( isset( $cached['body'] ) && is_array( $cached['body'] ) ? $cached['body'] : array() ); } $response = $this->transport->report( $function, $body ); if ( ! $response->is_ok() ) { // Cached too, briefly. A failure is usually a bad token or an expired one, and asking six // times per page load will not change the answer — it will only make the screen slow to // tell the owner what is wrong. set_transient( $key, array( 'error' => $response->error() ), 60 ); return Report::failed( $response->error() ); } set_transient( $key, array( 'body' => $response->body(), 'error' => '' ), self::TTL ); return Report::from_body( $response->body() ); } /** * Cache name for one request. * * @param string $function Report function name. * @param array $body Request body. * @return string */ private function cache_key( string $function, array $body ): string { $generation = (string) get_option( self::GENERATION, '0' ); $signature = wp_json_encode( $body ); // Transient names are capped at 172 characters, so the request is hashed rather than spelled out. return 'adpix_report_' . md5( $generation . '|' . $function . '|' . ( is_string( $signature ) ? $signature : '' ) ); } /** * Put back the days the API left out, and order the series by date. * * A day with no traffic is simply absent from the answer. Drawing those rows in the order they * arrived would produce a chart whose horizontal axis is not time. * * @param Report $report Time series as returned. * @return Report */ private function fill_days( Report $report ): Report { $start = $report->meta( 'start' ); $end = $report->meta( 'end' ); if ( '' === $start || '' === $end ) { return $report; } $width = count( $report->metrics() ); $known = array(); foreach ( $report->rows() as $row ) { $day = isset( $row['dimensions'][0] ) ? (string) $row['dimensions'][0] : ''; if ( '' !== $day ) { $known[ $day ] = $row; } } $cursor = strtotime( $start . ' 00:00:00 UTC' ); $last = strtotime( $end . ' 00:00:00 UTC' ); if ( false === $cursor || false === $last || $last < $cursor ) { return $report; } // Guards a hand-edited option or a future preset from turning one page load into a million rows. $limit = 400; $rows = array(); while ( $cursor <= $last && count( $rows ) < $limit ) { $day = gmdate( 'Y-m-d', $cursor ); $rows[] = $known[ $day ] ?? array( 'label' => $day, 'dimensions' => array( $day ), 'values' => array_fill( 0, max( 1, $width ), 0.0 ), ); $cursor += DAY_IN_SECONDS; } return $report->with_rows( $rows ); } /** * Order a breakdown by its first metric, largest first. * * The API orders rows for its own reasons; a table that says "top countries" has to be sorted by * the column the reader is comparing, not by whatever arrived. * * @param Report $report Breakdown as returned. * @return Report */ private function sort_by_first_metric( Report $report ): Report { $rows = $report->rows(); usort( $rows, static function ( array $a, array $b ): int { $left = isset( $a['values'][0] ) ? (float) $a['values'][0] : 0.0; $right = isset( $b['values'][0] ) ? (float) $b['values'][0] : 0.0; return $right <=> $left; } ); return $report->with_rows( $rows ); } }