diff --git a/css/dashboard.css b/css/dashboard.css index 0413e93..63f44cf 100644 --- a/css/dashboard.css +++ b/css/dashboard.css @@ -1,6 +1,6 @@ /* @group Front page */ -#statify_chart { +.statify-chart { color: #a7aaad; flex: 0 0 100%; height: 140px; @@ -10,34 +10,40 @@ text-align: center; } -#statify_chart * { +.statify-chart * { direction: ltr; } -body.rtl #statify_chart * { +body.rtl .statify-chart * { direction: rtl; } -#statify_chart .spinner { +.statify-chart .spinner { float: none; margin: 1em; } -#statify_chart .ct-line { +.statify-chart .ct-line { stroke: #3582c4; stroke-width: 2px; } -#statify_chart .ct-point { +.statify-chart .ct-point { fill: #fff; stroke: #3582c4; stroke-width: 1.5px; } -#statify_chart .ct-area { +.statify-chart .ct-area { fill: #3582c4; } +.statify-chart .ct-label.ct-horizontal.ct-end { + align-items: center; + justify-content: center; + margin-left: -50%; +} + .statify-chartist-tooltip { border: 1px solid #000; background-color: #fff; @@ -55,6 +61,29 @@ body.rtl #statify_chart * { color: #3582c4; } + +.statify-table th, +.statify-table tr.statify-table-sum td, +.statify-table td.statify-table-sum { + font-weight: 700; +} + +.statify-table tr.placeholder { + background-color: #fff; +} + +.statify-table tr.placeholder td span { + background-color: #f5f5f5; + border-radius: 1em; + display: inline-block; + width: 80%; +} + +.statify-table + a.button { + margin: 1em 0 0 1em; +} + + #statify_dashboard .postbox-title-action a, #statify_dashboard .settings-link a { display: block; @@ -139,4 +168,21 @@ body.rtl #statify_chart * { line-height: 28px; } +.statify-chart-container { + background: #fff; + margin: 1em 0; + padding: 1em; +} + +.statify-chart-container .statify-chart { + height: 280px; + margin: 10px 20px; +} + +.statify-chart-title { + text-align: center; + font-size: 1.5em; + padding: 0.5em 0; +} + /* @end group */ diff --git a/inc/class-statify-api.php b/inc/class-statify-api.php index c609bc6..e15dae9 100644 --- a/inc/class-statify-api.php +++ b/inc/class-statify-api.php @@ -22,9 +22,10 @@ class Statify_Api extends Statify { * * @var string */ - const REST_NAMESPACE = 'statify/v1'; + const REST_NAMESPACE = 'statify/v1'; const REST_ROUTE_TRACK = 'track'; const REST_ROUTE_STATS = 'stats'; + const REST_ROUTE_STATS_EXTENDED = 'stats/extended'; /** * Initialize REST API routes. @@ -55,6 +56,16 @@ public static function init(): void { ) ); + register_rest_route( + self::REST_NAMESPACE, + self::REST_ROUTE_STATS_EXTENDED, + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_extended' ), + 'permission_callback' => array( __CLASS__, 'user_can_see_stats' ), + ) + ); + add_filter( 'rest_authentication_errors', array( __CLASS__, 'check_authentication' ), 5 ); } @@ -103,7 +114,7 @@ public static function track_visit( WP_REST_Request $request ): WP_REST_Response if ( null !== $referrer ) { $referrer = filter_var( $referrer, FILTER_SANITIZE_URL ); } - $target = $request->get_param( 'target' ); + $target = $request->get_param( 'target' ); if ( null !== $target ) { $target = filter_var( $target, FILTER_SANITIZE_URL ); } @@ -124,7 +135,7 @@ public static function track_visit( WP_REST_Request $request ): WP_REST_Response public static function get_stats( WP_REST_Request $request ): WP_REST_Response { $refresh = '1' === $request->get_param( 'refresh' ); - $stats = Statify_Dashboard::get_stats( $refresh ); + $stats = Statify_Dashboard::get_stats( $refresh ); $visits = $stats['visits']; $stats['visits'] = array(); @@ -151,4 +162,111 @@ public static function get_stats( WP_REST_Request $request ): WP_REST_Response { return new WP_REST_Response( $stats ); } + + + /** + * Get extended statistics. + * + * @param WP_REST_Request $request The request. + * + * @return WP_REST_Response The response. + * + * @since 2.0.0 + */ + public static function get_extended( WP_REST_Request $request ): WP_REST_Response { + // Verify scope. + $scope = $request->get_param( 'scope' ); + if ( ! in_array( $scope, array( 'year', 'month', 'day' ) ) ) { + return new WP_REST_Response( + array( 'error' => 'invalid scope (allowed: year, month, day)' ), + 400 + ); + } + + // Parse year, if provided. + $yr = $request->get_param( 'year' ); + if ( ! empty( $yr ) ) { + $yr = intval( $yr ); + if ( $yr <= 0 ) { + return new WP_REST_Response( + array( 'error' => 'invalid year' ), + 400 + ); + } + } else { + $yr = 0; + } + + // Retrieve from cache, if data is not post-specific. + $post = $request->get_param( 'post' ); + $stats = false; + if ( ! $post ) { + $stats = self::from_cache( $scope, $yr ); + } + + if ( ! $stats ) { + if ( 'year' === $scope ) { + $stats = Statify_Evaluation::get_views_for_all_years( $post ); + } elseif ( 'month' === $scope ) { + $visits = Statify_Evaluation::get_views_for_all_months( $post ); + $stats = array( 'visits' => array() ); + $last_ym = 0; + foreach ( $visits as $ym => $v ) { + $ym = explode( '-', $ym ); + $year = intval( $ym[0] ); + $month = intval( $ym[1] ); + $year_month = $year * 12 + $month; + for ( $ym = $last_ym + 1; $last_ym > 0 && $ym < $year_month; $ym++ ) { + // Fill gaps. + $y = intval( $ym / 12 ); + if ( ! isset( $stats['visits'][ $y ] ) ) { + $stats['visits'][ $y ] = array(); + } + $stats['visits'][ $y ][ $ym % 12 ] = 0; + } + if ( ! isset( $stats['visits'][ $year ] ) ) { + $stats['visits'][ $year ] = array(); + } + $stats['visits'][ $year ][ $month ] = $v; + $last_ym = $year_month; + } + } elseif ( 'day' === $scope ) { + $stats = Statify_Evaluation::get_views_for_all_days( $yr, $post ); + } + + // Update cache, if data is not post-specific. + if ( ! $post ) { + self::update_cache( $scope, $yr, $stats ); + } + } + + return new WP_REST_Response( $stats ); + } + + /** + * Retrieve data from cache. + * + * @param string $scope Scope (year, month, day). + * @param int $index Optional index (e.g. year). + * + * @return array|false Transient data or FALSE. + */ + private static function from_cache( string $scope, int $index = 0 ) { + return get_transient( 'statify_data_' . $scope . ( $index > 0 ? '_' . $index : '' ) ); + } + + /** + * Update data cache. + * + * @param string $scope Scope (year, month, day). + * @param int $index Optional index (e.g. year). + * @param array $data Data. + */ + private static function update_cache( string $scope, int $index, array $data ): void { + set_transient( + 'statify_data_' . $scope . ( $index > 0 ? '_' . $index : '' ), + $data, + 30 * MINUTE_IN_SECONDS + ); + } } diff --git a/inc/class-statify-dashboard.php b/inc/class-statify-dashboard.php index 472ef32..890d643 100755 --- a/inc/class-statify-dashboard.php +++ b/inc/class-statify-dashboard.php @@ -18,14 +18,6 @@ */ class Statify_Dashboard extends Statify { - /** - * Plugin version. - * - * @since 1.4.0 - * @var string - */ - protected static $_plugin_version; - /** * Dashboard widget initialize * @@ -48,9 +40,6 @@ public static function init(): void { wp_normalize_path( sprintf( '%s/lang', STATIFY_DIR ) ) ); - // Plugin version. - self::_get_version(); - // Add dashboard widget. wp_add_dashboard_widget( 'statify_dashboard', @@ -60,82 +49,12 @@ public static function init(): void { ); // Init CSS. - add_action( 'admin_print_styles', array( __CLASS__, 'add_style' ) ); + add_action( 'admin_print_styles', array( 'Statify', 'add_style' ) ); // Init JS. - add_action( 'admin_print_scripts', array( __CLASS__, 'add_js' ) ); - } - - /** - * Print CSS - * - * @since 0.1.0 - * @version 1.4.0 - */ - public static function add_style(): void { - - // Register CSS. - wp_register_style( - 'chartist_css', - plugins_url( '/css/chartist.min.css', STATIFY_FILE ), - array(), - self::$_plugin_version - ); - wp_register_style( - 'chartist_tooltip_css', - plugins_url( '/css/chartist-plugin-tooltip.min.css', STATIFY_FILE ), - array(), - self::$_plugin_version - ); - wp_register_style( - 'statify', - plugins_url( '/css/dashboard.min.css', STATIFY_FILE ), - array(), - self::$_plugin_version - ); - - // Load CSS. - wp_enqueue_style( 'chartist_css' ); - wp_enqueue_style( 'chartist_tooltip_css' ); - wp_enqueue_style( 'statify' ); - } - - /** - * Print JavaScript - * - * @since 0.1.0 - * @version 1.4.0 - */ - public static function add_js(): void { - - // Register JS. - wp_register_script( - 'chartist_js', - plugins_url( 'js/chartist.min.js', STATIFY_FILE ), - array(), - self::$_plugin_version, - true - ); - wp_register_script( - 'chartist_tooltip_js', - plugins_url( 'js/chartist-plugin-tooltip.min.js', STATIFY_FILE ), - array( 'chartist_js' ), - self::$_plugin_version, - true - ); - wp_register_script( - 'statify_chart_js', - plugins_url( 'js/dashboard.min.js', STATIFY_FILE ), - array( 'wp-api-fetch', 'chartist_tooltip_js', 'wp-i18n' ), - self::$_plugin_version, - true - ); - - // Sets translated strings for the script. - wp_set_script_translations( 'statify_chart_js', 'statify' ); + add_action( 'admin_print_scripts', array( 'Statify', 'add_js' ) ); } - /** * Print widget frontview. * @@ -228,22 +147,6 @@ private static function _save_widget_options(): void { update_option( 'statify', $options ); } - - /** - * Set plugin version from plugin meta data - * - * @since 1.4.0 - * @version 1.4.0 - */ - private static function _get_version(): void { - - // Get plugin meta. - $meta = get_plugin_data( STATIFY_FILE ); - - self::$_plugin_version = $meta['Version']; - } - - /** * Get stats from cache * diff --git a/inc/class-statify-evaluation.php b/inc/class-statify-evaluation.php new file mode 100644 index 0000000..73addd4 --- /dev/null +++ b/inc/class-statify-evaluation.php @@ -0,0 +1,547 @@ +add_cap( self::CAPABILITY_SEE_STATS ); + } + } + } else { + // Backwards compatibility for older statify versions without this option. + $role = get_role( 'administrator' ); + if ( $role ) { + $role->add_cap( self::CAPABILITY_SEE_STATS ); + } + } + } + + /** + * Create an item and submenu items in the WordPress admin menu. + */ + public static function add_menu(): void { + add_menu_page( + __( 'Statify', 'statify' ), + 'Statify', + 'see_statify_evaluation', + 'statify_dashboard', + array( __CLASS__, 'show_dashboard' ), + 'dashicons-chart-area', + 50 + ); + } + + /** + * Show the dashboard page. + */ + public static function show_dashboard(): void { + self::add_js(); + self::add_style(); + wp_enqueue_script( 'chartist_js' ); + wp_enqueue_script( 'statify_chart_js' ); + + load_template( wp_normalize_path( STATIFY_DIR . '/views/view-dashboard.php' ) ); + } + + /** + * Returns the numeric values for all days in a month. + * + * @param int $month the month as value between 1 and 12 (default: 1). + * @param int $year a year (default: 0). + * + * @return int[] an array of integers (1, 2, ..., 31). + */ + public static function get_days( int $month = 1, int $year = 0 ): array { + if ( 2 === $month ) { + if ( checkdate( 2, 29, $year ) ) { + $last_day = 29; + } else { + $last_day = 28; + } + } elseif ( in_array( $month, array( 4, 6, 9, 11 ), true ) ) { + $last_day = 30; + } else { + $last_day = 31; + } + + return range( 1, $last_day ); + } + + /** + * Returns the numeric values of all months. + * + * @return int[] an array of integers (1, 2, ..., 12) + */ + public static function get_months(): array { + return range( 1, 12 ); + } + + /** + * Returns the years Statify has collected data for in descending order. + * + * @return int[] an array of integers (e.g. 2016, 2015) + */ + public static function get_years(): array { + global $wpdb; + + $results = $wpdb->get_results( + 'SELECT DISTINCT YEAR(`created`) as `year`' . + " FROM `$wpdb->statify` " . + ' ORDER BY `year` DESC', + ARRAY_A + ); + $years = array(); + foreach ( $results as $result ) { + $years[] = (int) $result['year']; + } + + return $years; + } + + /** + * Returns the views for all days. + * If the given URL is not the empty string, the result is restricted to the given post. + * + * @param int $single_year single year. + * @param string|null $post_url the URL of the post to select for (or the empty string for all posts). + * + * @return array an array with date as key and views as value + */ + public static function get_views_for_all_days( int $single_year = 0, ?string $post_url = '' ): array { + global $wpdb; + + $query = 'SELECT `created` as `date`, COUNT(`created`) as `count`' . + " FROM `$wpdb->statify`"; + $args = array(); + + if ( $single_year > 0 ) { + $query .= ' WHERE YEAR(`created`) = %d'; + $args[] = $single_year; + } + + if ( ! empty( $post_url ) ) { + $query .= ( $single_year > 0 ? ' AND' : ' WHERE' ) . ' `target` = %s'; + $args[] = $post_url; + } + + $query .= ' GROUP BY `created`' . + ' ORDER BY `created`'; + + if ( ! empty( $args ) ) { + $query = $wpdb->prepare( $query, $args ); + } + + $results = $wpdb->get_results( $query, ARRAY_A ); + + $views_for_all_days = array(); + foreach ( $results as $result ) { + $views_for_all_days[ $result['date'] ] = intval( $result['count'] ); + } + + return $views_for_all_days; + } + + /** + * Returns the views for one day. + * If the date does not exist (e.g. 30th February), this method returns -1. + * + * @param array $views_for_all_days an array with the daily views. + * @param int $year the year. + * @param int $month the month. + * @param int $day the day. + * + * @return int number the number of views (or -1 if the date is invalid). + */ + public static function get_daily_views( array $views_for_all_days, int $year, int $month, int $day ): int { + if ( checkdate( $month, $day, $year ) ) { + $date = sprintf( + '%s-%s-%s', + str_pad( $year, 4, '0', STR_PAD_LEFT ), + str_pad( $month, 2, '0', STR_PAD_LEFT ), + str_pad( $day, 2, '0', STR_PAD_LEFT ) + ); + if ( array_key_exists( $date, $views_for_all_days ) ) { + return $views_for_all_days[ $date ]; + } + + return 0; + } + + // No valid date. + return -1; + } + + /** + * Returns the views for all months. + * If the given URL is not the empty string, the result is restricted to the given post. + * + * @param string|null $post_url the URL of the post to select for (or the empty string for all posts). + * + * @return array an array with month as key and views as value. + */ + public static function get_views_for_all_months( ?string $post_url = '' ): array { + global $wpdb; + + if ( empty( $post_url ) ) { + // For all posts. + $results = $wpdb->get_results( + "SELECT DATE_FORMAT(`created`, '%Y-%m') as `date`, COUNT(`created`) as `count`" . + " FROM `$wpdb->statify`" . + ' GROUP BY `date`' . + ' ORDER BY `date`', + ARRAY_A + ); + } else { + // Only for selected posts. + $results = $wpdb->get_results( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnsupportedPlaceholder + "SELECT DATE_FORMAT(`created`, '%Y-%m') as `date`, COUNT(`created`) as `count` + FROM `$wpdb->statify` + WHERE `target` = %s + GROUP BY `date` + ORDER BY `date`", + $post_url + ), + ARRAY_A + ); + } + $views_for_all_months = array(); + foreach ( $results as $result ) { + $views_for_all_months[ $result['date'] ] = intval( $result['count'] ); + } + + return $views_for_all_months; + } + + /** + * Returns the views for one month. + * If the date does not exist (e.g. 30th February), this method returns -1. + * + * @param array $views_for_all_months an array with the monthly views. + * @param int $year the year. + * @param int $month the month. + * + * @return int the view for the given month. + */ + public static function get_monthly_views( array $views_for_all_months, int $year, int $month ): int { + if ( $month < 1 || $month > 12 ) { + return -1; + } + + $date = sprintf( + '%s-%s', + str_pad( $year, 4, '0', STR_PAD_LEFT ), + str_pad( $month, 2, '0', STR_PAD_LEFT ) + ); + if ( array_key_exists( $date, $views_for_all_months ) ) { + return $views_for_all_months[ $date ]; + } + + return 0; + } + + /** + * Returns the average daily views in the given month. + * + * @param array $views_for_all_months an array with the monthly views. + * @param int $year the year. + * @param int $month the month. + * + * @return int the average daily views in the given month. + */ + public static function get_average_daily_views_of_month( array $views_for_all_months, int $year, int $month ): int { + if ( $month < 1 || $month > 12 ) { + return -1; + } + + $views_in_month = self::get_monthly_views( $views_for_all_months, $year, $month ); + if ( self::is_current_month( $year, $month ) ) { + $days_in_month = (int) gmdate( 'd' ); + } else { + $days_in_month = count( self::get_days( $month, $year ) ); + } + + return (int) round( $views_in_month / $days_in_month ); + } + + /** + * Returns an array with the daily views for all days in the given month. + * If the given month is the current one, just the views for past days and the current day is included. + * + * @param array $views_for_all_days an array with the daily views. + * @param int $year the year. + * @param int $month the month. + * + * @return array array with the daily views for all days in the given day. + */ + public static function get_daily_views_of_month( array $views_for_all_days, int $year, int $month ): array { + if ( $month < 1 || $month > 12 ) { + return array(); + } + + if ( self::is_current_month( $year, $month ) ) { + $days = range( 1, (int) gmdate( 'd' ) ); + } else { + $days = self::get_days( $month, $year ); + } + + $views = array(); + foreach ( $days as $day ) { + $views[] = self::get_daily_views( $views_for_all_days, $year, $month, $day ); + } + + return $views; + } + + /** + * Returns whether the given month is the current one. + * + * @param int $year a year. + * @param int $month a month. + * + * @return bool true if and only if the given month is the current one. + */ + private static function is_current_month( int $year, int $month ): bool { + $current_year = (int) gmdate( 'Y' ); + $current_month = (int) gmdate( 'm' ); + + return $current_year === $year && $current_month === $month; + } + + /** + * Returns the views for all years. + * + * If the given URL is not the empty string, the result is restricted to the given post. + * + * @param string|null $post_url the URL of the post to select for (or the empty string for all posts). + * + * @return array an array with the year as key and views as value. + */ + public static function get_views_for_all_years( ?string $post_url = '' ): array { + global $wpdb; + + if ( empty( $post_url ) ) { + // For all posts. + $results = $wpdb->get_results( + 'SELECT YEAR(`created`) as `date`, COUNT(`created`) as `count`' . + " FROM `$wpdb->statify`" . + ' GROUP BY `date`', + ARRAY_A + ); + } else { + // Only for selected posts. + $results = $wpdb->get_results( + $wpdb->prepare( + 'SELECT YEAR(`created`) as `date`, COUNT(`created`) as `count`' . + " FROM `$wpdb->statify`" . + ' WHERE `target` = %s' . + ' GROUP BY `date`', + $post_url + ), + ARRAY_A + ); + } + $views_for_all_years = array(); + foreach ( $results as $result ) { + $views_for_all_years[ $result['date'] ] = intval( $result['count'] ); + } + + return $views_for_all_years; + } + + /** + * Returns the views for one year. If the date does not exist (e.g. 30th February), + * this method returns -1. + * + * @param array $views_for_all_years an array with the yearly views. + * @param int $year the year. + * + * @return int the views of the given year. + */ + public static function get_yearly_views( array $views_for_all_years, int $year ): int { + $year_key = str_pad( $year, 4, '0', STR_PAD_LEFT ); + if ( array_key_exists( $year_key, $views_for_all_years ) ) { + return $views_for_all_years[ $year_key ]; + } + + return 0; + } + + /** + * Returns the most popular posts with their views count (in the date period if set). + * + * @param string $start the start date of the period. + * @param string $end the end date of the period. + * + * @return array an array with the most popular posts, ordered by view count. + */ + public static function get_views_of_most_popular_posts( string $start = '', string $end = '' ): array { + global $wpdb; + + if ( empty( $start ) && empty( $end ) ) { + $results = $wpdb->get_results( + 'SELECT COUNT(`target`) as `count`, `target` as `url`' . + " FROM `$wpdb->statify`" . + ' GROUP BY `target`' . + ' ORDER BY `count` DESC', + ARRAY_A + ); + } else { + $results = $wpdb->get_results( + $wpdb->prepare( + 'SELECT COUNT(`target`) as `count`, `target` as `url`' . + " FROM `$wpdb->statify`" . + ' WHERE `created` >= %s AND `created` <= %s' . + ' GROUP BY `target`' . + ' ORDER BY `count` DESC', + $start, + $end + ), + ARRAY_A + ); + } + + foreach ( $results as &$result ) { + $result['count'] = intval( $result['count'] ); + } + + return $results; + } + + /** + * Returns the number of views for the post with the given URL (in the date period if set). + * + * @param string $url the post URL. + * @param string $start the start date of the period. + * @param string $end the end date of the period. + * + * @return int the number of views for the post. + */ + public static function get_views_of_post( string $url, string $start = '', string $end = '' ): int { + global $wpdb; + + if ( empty( $start ) && empty( $end ) ) { + $where = '`target` = %s'; + $param = $url; + } else { + $where = '`target` = %s AND `created` >= %s AND `created` <= %s'; + $param = array( $url, $start, $end ); + } + + $results = $wpdb->get_results( + $wpdb->prepare( + 'SELECT COUNT(`target`) as `count`' . + " FROM `$wpdb->statify`" . + ' WHERE ' . $where, + $param + ), + OBJECT + ); + + return intval( $results[0]->count ); + } + + /** + * Returns the most popular referrers with their views count. + * If the given URL is not the empty string, the result is restricted to the given post. + * + * @param string|null $post_url the URL of the post to select for (or the empty string for all posts). + * @param string|null $start the start date of the period. + * @param string|null $end the end date of the period. + * + * @return array an array with the most referrers, ordered by view count + */ + public static function get_views_for_all_referrers( ?string $post_url = '', ?string $start = '', ?string $end = '' ): array { + global $wpdb; + + if ( empty( $post_url ) ) { + // For all posts. + if ( empty( $start ) && empty( $end ) ) { + $where = "`referrer` != ''"; + $param = array(); + } else { + $where = "`referrer` != '' AND `created` >= %s AND `created` <= %s"; + $param = array( $start, $end ); + } + } elseif ( empty( $start ) && empty( $end ) ) { + // Only for selected posts. + $where = "`referrer` != '' AND target = %s"; + $param = array( $post_url ); + } else { + $where = "`referrer` != '' AND `target` = %s AND `created` >= %s AND `created` <= %s"; + $param = array( $post_url, $start, $end ); + } + + $stmt = 'SELECT COUNT(`referrer`) as `count`, `referrer` as `url`,' . + " SUBSTRING_INDEX(SUBSTRING_INDEX(TRIM(LEADING 'www.' FROM(TRIM(LEADING 'https://' FROM TRIM(LEADING 'http://' FROM TRIM(`referrer`))))), '/', 1), ':', 1) as `host`" . + " FROM `$wpdb->statify`" . + ' WHERE ' . $where . + ' GROUP BY `host`' . + ' ORDER BY `count` DESC'; + + if ( ! empty( $param ) ) { + $stmt = $wpdb->prepare( $stmt, $param ); + } + $results = $wpdb->get_results( $stmt, ARRAY_A ); + + foreach ( $results as &$result ) { + $result['count'] = intval( $result['count'] ); + } + + return $results; + } + + /** + * Returns a list of all target URLs. + * + * @return string[] an array of urls + */ + public static function get_post_urls(): array { + global $wpdb; + + return $wpdb->get_col( + 'SELECT DISTINCT `target`' . + " FROM `$wpdb->statify`" . + ' ORDER BY `target` ASC' + ); + } + + /** + * Returns the post types of the site: post, page and custom post types. + * + * @return string[] an array of post type slugs. + */ + public static function get_post_types(): array { + $types_args = array( + 'public' => true, + '_builtin' => false, + ); + + return array_merge( array( 'post', 'page' ), get_post_types( $types_args ) ); + } +} diff --git a/inc/class-statify.php b/inc/class-statify.php index d940a4e..78571c8 100755 --- a/inc/class-statify.php +++ b/inc/class-statify.php @@ -33,6 +33,13 @@ class Statify { */ public static $_options; + /** + * Plugin version. + * + * @var string|null $plugin_version + */ + private static $plugin_version; + /** * Plugin initialization. * @@ -76,6 +83,8 @@ public static function init(): void { add_filter( 'plugin_action_links_' . STATIFY_BASE, array( 'Statify_Backend', 'add_action_link' ) ); add_action( 'admin_init', array( 'Statify_Settings', 'register_settings' ) ); add_action( 'admin_menu', array( 'Statify_Settings', 'add_admin_menu' ) ); + add_action( 'admin_init', array( 'Statify_Evaluation', 'add_capability' ) ); + add_action( 'admin_menu', array( 'Statify_Evaluation', 'add_menu' ) ); add_action( 'update_option_statify', array( 'Statify_Settings', 'action_update_options' ), 10, 2 ); } else { // Frontend. add_action( 'template_redirect', array( 'Statify_Frontend', 'track_visit' ) ); @@ -225,6 +234,102 @@ public static function user_can_see_stats(): bool { return apply_filters( 'statify__user_can_see_stats', $can_see ); } + /** + * Print JavaScript. + * + * @since 2.0.0 Moved to Statify class + */ + public static function add_js(): void { + // Register JS. + wp_register_script( + 'chartist_js', + plugins_url( 'js/chartist.min.js', STATIFY_FILE ), + array(), + self::get_version(), + true + ); + wp_register_script( + 'chartist_tooltip_js', + plugins_url( 'js/chartist-plugin-tooltip.min.js', STATIFY_FILE ), + array( 'chartist_js' ), + self::get_version(), + true + ); + wp_register_script( + 'statify_chart_js', + plugins_url( 'js/dashboard.js', STATIFY_FILE ), + array( 'wp-api-fetch', 'chartist_tooltip_js' ), + self::get_version(), + true + ); + + // Localize strings. + wp_localize_script( + 'statify_chart_js', + 'statifyDashboard', + array( + 'i18n' => array( + 'sitename' => sanitize_key( get_bloginfo( 'name' ) ), + 'months' => array_map( + function ( $m ) { + return date_i18n( 'M', strtotime( '0000-' . $m . '-01' ) ); + }, + range( 1, 12 ) + ), + ), + ) + ); + } + + /** + * Print CSS + * + * @since 0.1.0 + * @since 2.0.0 Moved to Statify class + */ + public static function add_style(): void { + + // Register CSS. + wp_register_style( + 'chartist_css', + plugins_url( '/css/chartist.min.css', STATIFY_FILE ), + array(), + self::get_version() + ); + wp_register_style( + 'chartist_tooltip_css', + plugins_url( '/css/chartist-plugin-tooltip.min.css', STATIFY_FILE ), + array(), + self::get_version() + ); + wp_register_style( + 'statify', + plugins_url( '/css/dashboard.min.css', STATIFY_FILE ), + array(), + self::get_version() + ); + + // Load CSS. + wp_enqueue_style( 'chartist_css' ); + wp_enqueue_style( 'chartist_tooltip_css' ); + wp_enqueue_style( 'statify' ); + } + + /** + * Set plugin version from plugin meta data + * + * @since 1.4.0 + * @since 2.0.0 Moved up to Statify class. + */ + protected static function get_version(): string { + if ( ! isset( self::$plugin_version ) ) { + $meta = get_plugin_data( STATIFY_FILE ); + self::$plugin_version = $meta['Version']; + } + + return self::$plugin_version; + } + /** * Rules to skip the tracking. * diff --git a/js/dashboard.js b/js/dashboard.js index 183535e..c6f1781 100644 --- a/js/dashboard.js +++ b/js/dashboard.js @@ -12,6 +12,12 @@ ); const refreshBtn = document.getElementById('statify_refresh'); + const chartElemDaily = document.getElementById('statify_chart_daily'); + const chartElemMonthly = document.getElementById('statify_chart_monthly'); + const chartElemYearly = document.getElementById('statify_chart_yearly'); + const yearlyTable = document.getElementById('statify-table-yearly'); + const dailyTable = document.getElementById('statify-table-daily'); + /** * Update the dashboard widget * @@ -31,7 +37,7 @@ const labels = Object.keys(data.visits); const values = Object.values(data.visits); - render(chartElem, labels, values); + render(chartElem, labels, values, false); // Render top lists. if (referrerTable) { @@ -59,14 +65,92 @@ }); } + /** + * Render monthly statistics. + * + * @param {number} year Year to load data for. + * + * @return {Promise<{[key: string]: number}>} Data promise from API. + */ + function loadDaily(year) { + year = encodeURIComponent(year); + + // Load data from API. + return wp.apiFetch({ + path: `/statify/v1/stats/extended?scope=day&year=${year}`, + }); + } + + /** + * Render monthly statistics. + * + * @return {Promise<{visits: {[key: string]: {[key: string]: number}}}>} Data promise from API. + */ + function loadMonthly() { + // Load data from API. + return wp.apiFetch({ path: '/statify/v1/stats/extended?scope=month' }); + } + + /** + * Render daily statistics. + * + * @param {HTMLElement} root Root element. + * @param {{[key: string]: number}} data Data from API. + */ + function renderDaily(root, data) { + const labels = Object.keys(data); + const values = Object.values(data); + + render(root, labels, values); + } + + /** + * Render monthly statistics. + * + * @param {HTMLElement} root Root element. + * @param {{visits: {[key: string]: {[key: string]: number}}}} data Data from API. + */ + function renderMonthly(root, data) { + const labels = Object.keys(data.visits).flatMap((y) => + Object.keys(data.visits[y]).map( + (m) => statifyDashboard.i18n.months[m - 1] + ' ' + y + ) + ); + const values = Object.values(data.visits).flatMap((y) => + Object.values(y) + ); + + render(root, labels, values); + } + + /** + * Render yearly statistics. + * + * @param {HTMLElement} root Root element. + * @param {{visits: {[key: string]: {[key: string]: number}}}} data Data from API. + */ + function renderYearly(root, data) { + const labels = Object.keys(data.visits); + const values = Object.values(data.visits).flatMap((y) => + Object.values(y).reduce((a, b) => a + b, 0) + ); + + render(root, labels, values); + } + /** * Render statistics chart. * - * @param {HTMLElement} root Root element. - * @param {string[]} labels Labels. - * @param {number[]} values Values. + * @param {HTMLElement} root Root element. + * @param {string[]} labels Labels. + * @param {number[]} values Values. + * @param {boolean} showAxis Show X axis? */ - function render(root, labels, values) { + function render(root, labels, values, showAxis) { + if (typeof showAxis === 'undefined') { + showAxis = true; + } + // Remove the loading content. root.innerHTML = ''; @@ -103,8 +187,8 @@ width: fullWidth ? undefined : 5 * labels.length, axisX: { showGrid: false, - showLabel: false, - offset: 0, + showLabel: showAxis, + offset: showAxis ? 30 : 0, }, axisY: { showGrid: true, @@ -237,7 +321,141 @@ } } - // Abort if target element is not present. + /** + * Render yearly table. + * + * @param {HTMLElement} table Root element. + * @param {any} data Data from API. + */ + function renderYearlyTable(table, data) { + const tbody = table.querySelector('tbody'); + + tbody.innerHTML = ''; + + for (const year in data.visits) { + const row = document.createElement('TR'); + let col = document.createElement('TH'); + let sum = 0; + col.scope = 'row'; + col.innerText = year; + row.appendChild(col); + + for (let month = 1; month <= 12; month++) { + col = document.createElement('TD'); + col.innerText = data.visits[year][month - 1] || '-'; + row.appendChild(col); + sum += data.visits[year][month - 1] || 0; + } + + col = document.createElement('TD'); + col.classList.add('statify-table-sum'); + col.innerText = sum; + row.appendChild(col); + + tbody.insertBefore(row, tbody.firstChild); + } + + addExportButton(table); + } + + /** + * Render yearly table. + * + * @param {HTMLTableElement} table Root element. + * @param {any} data Data from API. + */ + function renderDailyTable(table, data) { + const rows = Array.from(table.querySelectorAll('tbody > tr')); + const cols = rows.map((row) => Array.from(row.querySelectorAll('td'))); + let out = cols.slice(0, 31); + + const sum = Array(12).fill(0); + const vls = Array(12).fill(0); + const min = Array(12).fill(Number.MAX_SAFE_INTEGER); + const max = Array(12).fill(0); + + for (const [day, count] of Object.entries(data)) { + const d = new Date(day); + const m = d.getMonth(); + sum[m] += count; + ++vls[m]; + min[m] = Math.min(min[m], count); + max[m] = Math.max(max[m], count); + out[d.getDate() - 1][m].innerText = count; + } + + out = + cols[ + rows.findIndex((row) => + row.classList.contains('statify-table-sum') + ) + ]; + const avg = + cols[ + rows.findIndex((row) => + row.classList.contains('statify-table-avg') + ) + ]; + for (const [m, s] of sum.entries()) { + if (vls[m] > 0) { + out[m].innerText = s; + avg[m].innerText = Math.round(s / vls[m]); + } else { + out[m].innerText = '-'; + avg[m].innerText = '-'; + } + } + + out = + cols[ + rows.findIndex((row) => + row.classList.contains('statify-table-min') + ) + ]; + for (const [m, s] of min.entries()) { + out[m].innerText = vls[m] > 0 ? s : '-'; + } + + out = + cols[ + rows.findIndex((row) => + row.classList.contains('statify-table-max') + ) + ]; + for (const [m, s] of max.entries()) { + out[m].innerText = vls[m] > 0 ? s : '-'; + } + + for (const row of rows) { + row.classList.remove('placeholder'); + } + + addExportButton(table); + } + + /** + * Convert daily to monthly data. + * + * @param {{[key: string]: number}} data Daily data. + * @return {{visits: {[key: string]: {[key: string]: number}}}} Monthly data. + */ + function dailyToMonthly(data) { + const monthly = { visits: {} }; + for (const [day, count] of Object.entries(data)) { + const date = new Date(day); + const year = date.getFullYear(); + const month = date.getMonth(); + + if (!(year in monthly.visits)) { + monthly.visits[year] = {}; + } + monthly.visits[year][month] = + count + (monthly.visits[year][month] || 0); + } + return monthly; + } + + // Abort if config or target element is not present. if (chartElem) { // Bind update function to "refresh" button. if (refreshBtn) { @@ -252,4 +470,77 @@ // Initial update. updateDashboard(false); } + + if (chartElemDaily) { + loadDaily(chartElemDaily.dataset.year).then((data) => { + renderDaily(chartElemDaily, data); + + if (chartElemMonthly) { + renderMonthly(chartElemMonthly, dailyToMonthly(data)); + } + + if (dailyTable) { + renderDailyTable(dailyTable, data); + } + }); + } else if (chartElemMonthly) { + loadMonthly() + .then((data) => { + renderMonthly(chartElemMonthly, data); + + if (chartElemYearly) { + renderYearly(chartElemYearly, data); + } + + if (yearlyTable) { + renderYearlyTable(yearlyTable, data); + } + }) + .catch(() => { + // Failed to load. + chartElem.innerHTML = + '
' + + wp.i18n.__('Error loading data.', 'statify') + + '
'; + }); + } + + /** + * Add a CSV export button to a table. + * + * @param {HTMLTableElement} table Table to process + */ + function addExportButton(table) { + const exportBtn = document.createElement('a'); + exportBtn.classList.add('button'); + exportBtn.role = 'button'; + + // Generate filename from table caption. + exportBtn.download = + statifyDashboard.i18n.sitename + + '-' + + table.caption.innerText.replaceAll(/\s+/g, '_') + + '-export-' + + new Date() + .toISOString() + .replace('T', '_') + .replaceAll(':', '-') + .substring(0, 16) + + '.csv'; + + // Generate CSV on demand. + exportBtn.innerText = wp.i18n.__('Export (CSV)', 'statify'); + exportBtn.addEventListener('click', () => { + exportBtn.href = + 'data:text/csv;charset=utf-8,' + + Array.from(table.rows) + .map((row) => + Array.from(row.cells) + .map((col) => col.innerText) + .join(',') + ) + .join('\r\n'); + }); + table.after(exportBtn); + } } diff --git a/statify.php b/statify.php index 1c4623a..9736b53 100644 --- a/statify.php +++ b/statify.php @@ -67,6 +67,7 @@ function statify_autoload( $class ) { 'Statify', 'Statify_Api', 'Statify_Backend', + 'Statify_Evaluation', 'Statify_Frontend', 'Statify_Dashboard', 'Statify_Install', diff --git a/tests/test-dashboard.php b/tests/test-dashboard.php index c7d0d83..87336a4 100644 --- a/tests/test-dashboard.php +++ b/tests/test-dashboard.php @@ -86,11 +86,11 @@ public function test_init() { 'Unexpected control callback' ); $this->assertNotFalse( - has_action( 'admin_print_styles', array( Statify_Dashboard::class, 'add_style' ) ), + has_action( 'admin_print_styles', array( Statify::class, 'add_style' ) ), 'Styles not added' ); $this->assertNotFalse( - has_action( 'admin_print_scripts', array( Statify_Dashboard::class, 'add_js' ) ), + has_action( 'admin_print_scripts', array( Statify::class, 'add_js' ) ), 'Scripts not added' ); } diff --git a/tests/test-evaluation.php b/tests/test-evaluation.php new file mode 100644 index 0000000..3d4a58e --- /dev/null +++ b/tests/test-evaluation.php @@ -0,0 +1,410 @@ +insert_test_data( '2023-03-25' ); + self::assertSame( array( 2023 ), Statify_Evaluation::get_years() ); + $this->insert_test_data( '2023-03-24' ); + $this->insert_test_data( '2022-03-04' ); + $this->insert_test_data( '2024-05-06' ); + $this->insert_test_data( '2020-01-02' ); + self::assertSame( array( 2024, 2023, 2022, 2020 ), Statify_Evaluation::get_years() ); + } + + /** + * Test views for all days. + */ + public function test_get_views_for_all_days() { + $this->insert_test_data( '2022-10-20', '', '/test/' ); + $this->insert_test_data( '2023-03-23', '', '/', 3 ); + $this->insert_test_data( '2023-03-23', '', '/test/' ); + $this->insert_test_data( '2023-03-25', '', '/' ); + $this->insert_test_data( '2023-03-25', '', '/test/', 2 ); + + self::assertSame( + array( + '2022-10-20' => 1, + '2023-03-23' => 4, + '2023-03-25' => 3, + ), + Statify_Evaluation::get_views_for_all_days(), + 'unexpected results without any filter' + ); + self::assertSame( + array( + '2022-10-20' => 1, + '2023-03-23' => 1, + '2023-03-25' => 2, + ), + Statify_Evaluation::get_views_for_all_days( 0, '/test/' ), + 'unexpected results with post filter' + ); + self::assertSame( + array( + '2023-03-23' => 4, + '2023-03-25' => 3, + ), + Statify_Evaluation::get_views_for_all_days( 2023 ), + 'unexpected results with year filter' + ); + self::assertSame( + array( + '2023-03-23' => 1, + '2023-03-25' => 2, + ), + Statify_Evaluation::get_views_for_all_days( 2023, '/test/' ), + 'unexpected results with year and post filter' + ); + } + + /** + * Test views for a single day. + */ + public function test_get_daily_views() { + $all = array( + '2023-03-25' => 3, + '2023-03-23' => 4, + '2023-03-22' => 5, + ); + + self::assertSame( 3, Statify_Evaluation::get_daily_views( $all, 2023, 3, 25 ) ); + self::assertSame( 5, Statify_Evaluation::get_daily_views( $all, 2023, 3, 22 ) ); + self::assertSame( 0, Statify_Evaluation::get_daily_views( $all, 2023, 3, 21 ) ); + self::assertSame( -1, Statify_Evaluation::get_daily_views( $all, 2023, 3, 32 ) ); + } + + /** + * Test views for all months. + */ + public function test_get_views_for_all_months() { + $this->insert_test_data( '2023-02-23', '', '/', 3 ); + $this->insert_test_data( '2023-02-22', '', '/test/' ); + $this->insert_test_data( '2023-03-24', '', '/' ); + $this->insert_test_data( '2023-03-25', '', '/test/', 2 ); + + self::assertSame( + array( + '2023-02' => 4, + '2023-03' => 3, + ), + Statify_Evaluation::get_views_for_all_months() + ); + self::assertSame( + array( + '2023-02' => 1, + '2023-03' => 2, + ), + Statify_Evaluation::get_views_for_all_months( '/test/' ) + ); + } + + /** + * Test views for a single month. + */ + public function test_get_monthly_views() { + $all = array( + '2023-03' => 3, + '2023-02' => 4, + '2023-01' => 5, + ); + + self::assertSame( 3, Statify_Evaluation::get_monthly_views( $all, 2023, 3 ) ); + self::assertSame( 5, Statify_Evaluation::get_monthly_views( $all, 2023, 1 ) ); + self::assertSame( 0, Statify_Evaluation::get_monthly_views( $all, 2023, 4 ) ); + self::assertSame( -1, Statify_Evaluation::get_monthly_views( $all, 2023, 13 ) ); + } + + /** + * Test average views for a single month. + */ + public function test_get_average_daily_views_of_month() { + $now = new DateTime(); + $all = array( + $now->format( 'Y-m' ) => intval( $now->format( 'd' ) ) * 25, + '2023-02' => 1400, + '2023-01' => 2325, + ); + + self::assertSame( + 25, + Statify_Evaluation::get_average_daily_views_of_month( $all, intval( $now->format( 'Y' ) ), intval( $now->format( 'm' ) ) ) + ); + self::assertSame( 50, Statify_Evaluation::get_average_daily_views_of_month( $all, 2023, 2 ) ); + self::assertSame( 75, Statify_Evaluation::get_average_daily_views_of_month( $all, 2023, 1 ) ); + self::assertSame( 0, Statify_Evaluation::get_average_daily_views_of_month( $all, 2022, 12 ) ); + self::assertSame( -1, Statify_Evaluation::get_average_daily_views_of_month( $all, 2023, 13 ) ); + } + + /** + * Test daily views for a single month. + */ + public function test_get_daily_views_of_month() { + $now = new DateTime(); + + $all = array( + $now->format( 'Y-m-01' ) => 5, + '2023-02-15' => 6, + '2023-02-10' => 7, + '2023-02-05' => 8, + ); + + $current_month = array_fill( 0, intval( $now->format( 'd' ) ), 0 ); + $current_month[0] = 5; + self::assertSame( + $current_month, + Statify_Evaluation::get_daily_views_of_month( $all, intval( $now->format( 'Y' ) ), intval( $now->format( 'm' ) ) ) + ); + self::assertSame( + array( 0, 0, 0, 0, 8, 0, 0, 0, 0, 7, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ), + Statify_Evaluation::get_daily_views_of_month( $all, 2023, 2 ) + ); + self::assertSame( + array( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ), + Statify_Evaluation::get_daily_views_of_month( $all, 2023, 1 ) + ); + self::assertSame( array(), Statify_Evaluation::get_daily_views_of_month( $all, 2023, 13 ) ); + } + + /** + * Test views for all years. + */ + public function test_get_views_for_all_years() { + $this->insert_test_data( '2022-02-23', '', '/', 3 ); + $this->insert_test_data( '2022-02-22', '', '/test/' ); + $this->insert_test_data( '2023-03-24', '', '/' ); + $this->insert_test_data( '2023-03-25', '', '/test/', 2 ); + + self::assertSame( + array( + '2022' => 4, + '2023' => 3, + ), + Statify_Evaluation::get_views_for_all_years() + ); + self::assertSame( + array( + '2022' => 1, + '2023' => 2, + ), + Statify_Evaluation::get_views_for_all_years( '/test/' ) + ); + } + /** + * Test views for a single year. + */ + public function test_get_yearly_views() { + $all = array( + '2023' => 3, + '2022' => 4, + '2021' => 5, + ); + + self::assertSame( 0, Statify_Evaluation::get_yearly_views( $all, 2024 ) ); + self::assertSame( 3, Statify_Evaluation::get_yearly_views( $all, 2023 ) ); + self::assertSame( 5, Statify_Evaluation::get_yearly_views( $all, 2021 ) ); + self::assertSame( 0, Statify_Evaluation::get_yearly_views( $all, 2020 ) ); + } + + /** + * Test views for most popular posts. + */ + public function test_get_views_of_most_popular_posts() { + $this->insert_test_data( '2023-03-22', '', '/' ); + $this->insert_test_data( '2023-03-23', '', '/test/', 3 ); + $this->insert_test_data( '2023-03-24', '', '/' ); + $this->insert_test_data( '2023-03-25', '', '/foo/', 4 ); + + self::assertSame( + array( + array( + 'count' => 4, + 'url' => '/foo/', + ), + array( + 'count' => 3, + 'url' => '/test/', + ), + array( + 'count' => 2, + 'url' => '/', + ), + ), + Statify_Evaluation::get_views_of_most_popular_posts() + ); + + self::assertSame( + array( + array( + 'count' => 3, + 'url' => '/test/', + ), + array( + 'count' => 1, + 'url' => '/', + ), + ), + Statify_Evaluation::get_views_of_most_popular_posts( '2023-03-23', '2023-03-24' ) + ); + } + + /** + * Test views for a single post. + */ + public function test_get_views_of_post() { + $this->insert_test_data( '2023-03-22', '', '/' ); + $this->insert_test_data( '2023-03-23', '', '/test/', 3 ); + $this->insert_test_data( '2023-03-24', '', '/' ); + $this->insert_test_data( '2023-03-25', '', '/foo/', 4 ); + + self::assertSame( 2, Statify_Evaluation::get_views_of_post( '/' ) ); + self::assertSame( 3, Statify_Evaluation::get_views_of_post( '/test/' ) ); + + self::assertSame( + 1, + Statify_Evaluation::get_views_of_post( '/', '2023-03-01', '2023-03-23' ) + ); + } + + /** + * Test views for all referrers. + */ + public function test_get_views_for_all_referrers() { + $this->insert_test_data( '2023-03-22', 'https://example.com', '/' ); + $this->insert_test_data( '2023-03-23', 'https://example.com/foo', '/test/', 2 ); + $this->insert_test_data( '2023-03-24', 'http://example.org', '/' ); + $this->insert_test_data( '2023-03-24', '', '/' ); + $this->insert_test_data( '2023-03-25', 'https://pluginkollektiv.de/', '/foo/', 4 ); + + self::assertSame( + array( + array( + 'count' => 4, + 'url' => 'https://pluginkollektiv.de/', + 'host' => 'pluginkollektiv.de', + ), + array( + 'count' => 3, + 'url' => 'https://example.com', + 'host' => 'example.com', + ), + array( + 'count' => 1, + 'url' => 'http://example.org', + 'host' => 'example.org', + ), + ), + Statify_Evaluation::get_views_for_all_referrers() + ); + + self::assertSame( + array( + array( + 'count' => 2, + 'url' => 'https://example.com/foo', + 'host' => 'example.com', + ), + array( + 'count' => 1, + 'url' => 'http://example.org', + 'host' => 'example.org', + ), + ), + Statify_Evaluation::get_views_for_all_referrers( '', '2023-03-23', '2023-03-24' ) + ); + + self::assertSame( + array( + array( + 'count' => 4, + 'url' => 'https://pluginkollektiv.de/', + 'host' => 'pluginkollektiv.de', + ), + ), + Statify_Evaluation::get_views_for_all_referrers( '/foo/' ) + ); + + self::assertSame( + array( + array( + 'count' => 1, + 'url' => 'https://example.com', + 'host' => 'example.com', + ), + ), + Statify_Evaluation::get_views_for_all_referrers( '/', '2023-03-20', '2023-03-23' ) + ); + } + + /** + * Test post URLs. + */ + public function test_get_post_urls() { + $this->insert_test_data( '2023-03-22', '', '/' ); + $this->insert_test_data( '2023-03-23', '', '/test/', 3 ); + $this->insert_test_data( '2023-03-24', '', '/' ); + $this->insert_test_data( '2023-03-25', '', '/foo/', 4 ); + + self::assertSame( + array( '/', '/foo/', '/test/' ), + Statify_Evaluation::get_post_urls() + ); + } +} diff --git a/views/view-dashboard.php b/views/view-dashboard.php new file mode 100644 index 0000000..37c4efe --- /dev/null +++ b/views/view-dashboard.php @@ -0,0 +1,237 @@ + ++ + | + + | + | |||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
+ | + | + | + | + | + | + | + | + | + | + | + | + | + |
+ + | + + | |||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
+ | + | + | + | + | + | + | + | + | + | + | + | + |
+ | + | + | + | + | + | + | + | + | + | + | + | + |
+ | + | + | + | + | + | + | + | + | + | + | + | + |
+ | + | + | + | + | + | + | + | + | + | + | + | + |
+ | + | + | + | + | + | + | + | + | + | + | + | + |