appointments = new AppointmentsRepository(); $this->services = new ServicesRepository(); $this->staff = new StaffRepository(); $this->locations = new LocationsRepository(); $this->availability = new AvailabilityRepository(); } /** Report managers: every card. */ public function render_dashboard(): void { if ( ! current_user_can( 'abientot_view_reports' ) ) { wp_die( esc_html__( 'You do not have permission to view reports.', 'abientot' ) ); } $context = new ReportContext( \Abientot\Support\Request::view_fields( ReportContext::QUERY_KEYS ), true, array( 'page' => 'abientot-dashboard' ), (bool) apply_filters( 'abientot_report_dimension_filters', false ) ); $this->render_page( $context, $this->cards( $context ) ); } /** * Shared viewers: only the given shareable cards. The caller has authorized the viewer. * * @param array $keys Granted card keys. * @param array $base_query Page query that renders this view. */ public function render_shared( array $keys, array $base_query ): void { $context = new ReportContext( \Abientot\Support\Request::view_fields( ReportContext::QUERY_KEYS ), false, $base_query, false ); $cards = array_filter( $this->cards( $context ), static fn( array $card, string $key ): bool => ! empty( $card['shareable'] ) && in_array( $key, $keys, true ), ARRAY_FILTER_USE_BOTH ); $this->render_page( $context, $cards ); } /** * Card registry. Keys: title, description, icon, span (4|6|8|12), order, shareable, * collapsed (default state), table (has a table view), headline (callable|null), render (callable). * * @return array> */ public function cards( ReportContext $context ): array { $cards = array( 'overview' => array( 'title' => __( 'Key figures', 'abientot' ), 'description' => __( 'Headline numbers compared with the previous period.', 'abientot' ), 'icon' => 'dashicons-performance', 'span' => 12, 'order' => 10, 'shareable' => true, 'render' => array( $this, 'card_overview' ) ), 'trend' => array( 'title' => __( 'Bookings over time', 'abientot' ), 'description' => __( 'This period against the previous one.', 'abientot' ), 'icon' => 'dashicons-chart-bar', 'span' => 8, 'order' => 20, 'shareable' => true, 'table' => true, 'render' => array( $this, 'card_trend' ) ), 'health' => array( 'title' => __( 'Booking health', 'abientot' ), 'description' => __( 'How bookings in this period ended up.', 'abientot' ), 'icon' => 'dashicons-heart', 'span' => 4, 'order' => 30, 'shareable' => true, 'table' => true, 'render' => array( $this, 'card_health' ) ), 'services' => array( 'title' => __( 'Top services', 'abientot' ), 'description' => __( 'What brings in the most value.', 'abientot' ), 'icon' => 'dashicons-star-filled', 'span' => 6, 'order' => 40, 'shareable' => true, 'table' => true, 'render' => array( $this, 'card_services' ) ), 'busy' => array( 'title' => __( 'Busiest times', 'abientot' ), 'description' => __( 'Bookings by weekday and start hour.', 'abientot' ), 'icon' => 'dashicons-clock', 'span' => 6, 'order' => 50, 'shareable' => true, 'table' => true, 'render' => array( $this, 'card_busy' ) ), 'booking' => array( 'title' => __( 'Booking behaviour', 'abientot' ), 'description' => __( 'How far ahead and through which channel clients book.', 'abientot' ), 'icon' => 'dashicons-calendar-alt', 'span' => 6, 'order' => 80, 'shareable' => true, 'table' => true, 'render' => array( $this, 'card_booking' ) ), 'clients' => array( 'title' => __( 'Customers', 'abientot' ), 'description' => __( 'New and returning customers in this period.', 'abientot' ), 'icon' => 'dashicons-groups', 'span' => 12, 'order' => 90, 'shareable' => true, 'render' => array( $this, 'card_customers' ) ), 'upcoming' => array( 'title' => __( 'Next 7 days', 'abientot' ), 'description' => __( 'Booked time ahead and bookings waiting for confirmation.', 'abientot' ), 'icon' => 'dashicons-calendar', 'span' => 6, 'order' => 70, 'shareable' => true, 'render' => array( $this, 'card_upcoming' ) ), ); if ( $context->owner && current_user_can( 'abientot_manage_settings' ) ) { $checklist = $this->setup_checklist(); if ( in_array( false, array_column( $checklist, 'complete' ), true ) ) { $cards['setup'] = array( 'title' => __( 'Finish setting up', 'abientot' ), 'description' => __( 'Configuration items that affect booking readiness.', 'abientot' ), 'icon' => 'dashicons-admin-tools', 'span' => 6, 'order' => 5, 'shareable' => false, 'render' => fn() => $this->render_checklist( $checklist ) ); } } if ( $context->owner && current_user_can( 'abientot_manage_settings' ) ) { $cards['share'] = array( 'title' => __( 'Share your booking page', 'abientot' ), 'description' => __( 'Your public booking link, a QR code and where to post them.', 'abientot' ), 'icon' => 'dashicons-share', 'span' => 6, 'order' => 6, 'shareable' => false, 'render' => array( ShareKit::class, 'render' ) ); } $cards = (array) apply_filters( 'abientot_report_cards', $cards, $context ); uasort( $cards, static fn( array $a, array $b ): int => ( (int) ( $a['order'] ?? 500 ) ) <=> ( (int) ( $b['order'] ?? 500 ) ) ); return $cards; } /** @param array> $cards */ private function render_page( ReportContext $context, array $cards ): void { ?>

timezone ) ); ?>

owner ? __( 'How your business is doing, at a glance.', 'abientot' ) : __( 'Report cards the business owner shared with you.', 'abientot' ) ); ?>

1 ) : ?>
render_filters( $context ); ?>
$card ) { $this->render_card( (string) $key, $card, $context ); } ?>
__( 'Today', 'abientot' ), '7d' => __( '7 days', 'abientot' ), '30d' => __( '30 days', 'abientot' ), 'month' => __( 'This month', 'abientot' ), 'last-month' => __( 'Last month', 'abientot' ), '90d' => __( '90 days', 'abientot' ), 'year' => __( 'This year', 'abientot' ), ); $filters = $context->owner && apply_filters( 'abientot_report_dimension_filters', false ); ?>
base_query as $name => $value ) : ?>
currencies ) > 1 ) : ?> insights->active_staff(); ?> 1 || \Abientot\Support\StaffFilter::enabled() ) : ?> insights->active_locations(); ?> 1 ) : ?>

period_label() ); ?> period_label( true ) ) ); ?> currencies ) > 1 ) : ?>currency ) ); ?>

$card */ private function render_card( string $key, array $card, ReportContext $context ): void { $id = 'abientot-rp-' . sanitize_key( $key ); $span = in_array( (int) ( $card['span'] ?? 6 ), array( 4, 6, 8, 12 ), true ) ? (int) $card['span'] : 6; ob_start(); try { call_user_func( $card['render'], $context, $key ); $body = (string) ob_get_clean(); } catch ( \Throwable $error ) { ob_end_clean(); $body = ''; } ?>
aria-labelledby="-title">
data( 'summary' . ( $previous ? '-previous' : '' ), fn() => $context->insights->summary( $context->scope( $previous ) ) ); } /** Granularity that keeps a chart between roughly 7 and 31 columns. */ private function granularity( ReportContext $context ): string { return 1 === $context->days ? 'hour' : ( $context->days <= 31 ? 'day' : ( $context->days <= 126 ? 'week' : 'month' ) ); } private function bucket_key( DateTimeImmutable $local, string $granularity ): string { switch ( $granularity ) { case 'hour': return $local->format( 'H' ); case 'week': return Locale::week_start_date( $local )->format( 'Y-m-d' ); case 'month': return $local->format( 'Y-m' ); default: return $local->format( 'Y-m-d' ); } } /** * Ordered local buckets for a period, filled from UTC hour rows. * * @return array */ private function buckets( ReportContext $context, bool $previous ): array { return $context->data( 'buckets' . ( $previous ? '-previous' : '' ), function () use ( $context, $previous ): array { $granularity = $this->granularity( $context ); $start = $previous ? $context->previous_start : $context->start; $end = $previous ? $context->previous_end : $context->end; $step = array( 'hour' => '+1 hour', 'day' => '+1 day', 'week' => '+1 week', 'month' => '+1 month' )[ $granularity ]; $cursor = 'week' === $granularity ? Locale::week_start_date( $start ) : ( 'month' === $granularity ? $start->modify( 'first day of this month' ) : $start ); $buckets = array(); for ( $guard = 0; $cursor < $end && $guard < 400; ++$guard, $cursor = $cursor->modify( $step ) ) { $buckets[ $this->bucket_key( $cursor, $granularity ) ] = array( 'start' => $cursor, 'booking_count' => 0, 'revenue_cents' => 0, 'active_count' => 0 ); } foreach ( $this->hourly( $context, $previous ) as $row ) { $local = ( new DateTimeImmutable( $row['bucket'], new DateTimeZone( 'UTC' ) ) )->setTimezone( $context->timezone ); $key = $this->bucket_key( $local, $granularity ); if ( isset( $buckets[ $key ] ) ) { foreach ( array( 'booking_count', 'revenue_cents', 'active_count' ) as $field ) { $buckets[ $key ][ $field ] += $row[ $field ]; } } } return $buckets; } ); } private function hourly( ReportContext $context, bool $previous = false ): array { return $context->data( 'hourly' . ( $previous ? '-previous' : '' ), fn() => $context->insights->hourly( $context->scope( $previous ) ) ); } /** Scheduled working minutes for the active (or filtered) staff over a period. */ /** * Staff occupancy per team member (active or filtered staff) over a period. * * @return array */ public static function occupancy( ReportContext $context, bool $previous = false ): array { return $context->data( 'occupancy' . ( $previous ? '-previous' : '' ), function () use ( $context, $previous ): array { $staff = $context->insights->active_staff(); if ( is_int( $context->staff_id ) && $context->staff_id > 0 ) { $staff = array_values( array_filter( $staff, static fn( array $row ): bool => (int) $row['id'] === $context->staff_id ) ); } elseif ( 'unassigned' === $context->staff_id ) { return array(); } $start = $previous ? $context->previous_start : $context->start; $end = ( $previous ? $context->previous_end : $context->end )->modify( '-1 day' ); return $context->insights->occupancy( $context->scope( $previous ), $staff, $start->format( 'Y-m-d' ), $end->format( 'Y-m-d' ), $context->timezone ); } ); } /** @return array{scheduled:int,booked:int,outside:int} Team totals. */ private function occupancy_total( ReportContext $context, bool $previous = false ): array { $total = array( 'scheduled' => 0, 'booked' => 0, 'outside' => 0 ); foreach ( self::occupancy( $context, $previous ) as $row ) { foreach ( $total as $key => $value ) { $total[ $key ] = $value + $row[ $key ]; } } return $total; } /* --------------------------------------------------------------- Cards */ public function card_overview( ReportContext $context ): void { $now = $this->summary( $context ); $before = $this->summary( $context, true ); $buckets = $this->buckets( $context, false ); $customers = $context->data( 'customers', fn() => $context->insights->customers( $context->scope() ) ); $earlier = $context->data( 'customers-previous', fn() => $context->insights->customers( $context->scope( true ) ) ); $occupancy = $this->occupancy_total( $context ); $earlier_o = $this->occupancy_total( $context, true ); $scheduled = $occupancy['scheduled']; $staffed = $occupancy['booked'] + $occupancy['outside']; $average = $now['revenue_booking_count'] ? $now['revenue_cents'] / $now['revenue_booking_count'] : 0; $was = $before['revenue_booking_count'] ? $before['revenue_cents'] / $before['revenue_booking_count'] : 0; $lost = $now['cancelled_count'] + $now['no_show_count']; $lost_was = $before['cancelled_count'] + $before['no_show_count']; $tiles = array( array( 'label' => __( 'Booking value', 'abientot' ), 'value' => $context->money( $now['revenue_cents'] ), 'delta' => ReportCharts::delta( $now['revenue_cents'], $before['revenue_cents'] ), /* translators: %s: amount of money in the previous period. */ 'meta' => sprintf( __( 'Previous: %s', 'abientot' ), $context->money( $before['revenue_cents'] ) ), 'spark' => ReportCharts::sparkline( array_column( $buckets, 'revenue_cents' ), ReportCharts::SERIES[0] ), 'tone' => 'primary', ), array( 'label' => __( 'Bookings', 'abientot' ), 'value' => ReportCharts::number( $now['booking_count'] ), 'delta' => ReportCharts::delta( $now['booking_count'], $before['booking_count'] ), /* translators: %s: hours of team time taken by bookings. */ 'meta' => $staffed > 0 ? sprintf( __( '%s of team time', 'abientot' ), ReportCharts::hours( $staffed ) ) : '', 'spark' => ReportCharts::sparkline( array_column( $buckets, 'booking_count' ), ReportCharts::SERIES[1] ), ), array( 'label' => __( 'Average booking value', 'abientot' ), 'value' => $context->money( (int) round( $average ) ), 'delta' => ReportCharts::delta( $average, $was ), 'meta' => __( 'Bookings that count toward value', 'abientot' ), ), array( 'label' => __( 'Occupancy', 'abientot' ), 'value' => $scheduled > 0 ? ReportCharts::percent( $occupancy['booked'] / $scheduled ) : '—', 'delta' => $scheduled > 0 && $earlier_o['scheduled'] > 0 ? ReportCharts::delta( $occupancy['booked'] / $scheduled, $earlier_o['booked'] / $earlier_o['scheduled'], true, true ) : '', /* translators: 1: booked hours, 2: available working hours. */ 'meta' => $scheduled > 0 ? sprintf( __( '%1$s of %2$s working time', 'abientot' ), ReportCharts::hours( $occupancy['booked'] ), ReportCharts::hours( $scheduled ) ) : __( 'Add working hours to measure occupancy', 'abientot' ), 'meter' => $scheduled > 0 ? ReportCharts::meter( $occupancy['booked'] / $scheduled, __( 'Occupancy', 'abientot' ) ) : '', ), array( 'label' => __( 'Cancellations and no-shows', 'abientot' ), 'value' => $now['booking_count'] ? ReportCharts::percent( $lost / $now['booking_count'] ) : '—', 'delta' => $now['booking_count'] && $before['booking_count'] ? ReportCharts::delta( $lost / $now['booking_count'], $lost_was / $before['booking_count'], false, true ) : '', /* translators: 1: number of cancelled bookings, 2: number of no-shows. */ 'meta' => sprintf( __( '%1$s cancelled · %2$s no-shows', 'abientot' ), ReportCharts::number( $now['cancelled_count'] ), ReportCharts::number( $now['no_show_count'] ) ), ), array( 'label' => __( 'New customers', 'abientot' ), 'value' => ReportCharts::number( $customers['new_count'] ), 'delta' => ReportCharts::delta( $customers['new_count'], $earlier['new_count'] ), /* translators: %s: number of returning customers. */ 'meta' => sprintf( __( '%s returning', 'abientot' ), ReportCharts::number( max( 0, $customers['customer_count'] - $customers['new_count'] ) ) ), ), ); echo '
'; foreach ( $tiles as $tile ) { echo '
' . esc_html( $tile['label'] ) . '' . esc_html( $tile['value'] ) . ''; echo '' . wp_kses( $tile['delta'], self::allowed_html() ) . '' . esc_html( $tile['meta'] ) . ''; echo wp_kses( ( $tile['spark'] ?? '' ) . ( $tile['meter'] ?? '' ), self::allowed_html() ) . '
'; } echo '
'; } public function card_trend( ReportContext $context ): void { $granularity = $this->granularity( $context ); $current = array_values( $this->buckets( $context, false ) ); $previous = array_values( $this->buckets( $context, true ) ); $now = new DateTimeImmutable( 'now', $context->timezone ); $time_format = DateTimeHelper::time_display_format(); $money = array(); $count = array(); $rows = array(); foreach ( $current as $index => $bucket ) { $start = $bucket['start']; switch ( $granularity ) { case 'hour': $label = Locale::date( $time_format, $start->getTimestamp(), $context->timezone ); $short = $start->format( 'G' ); $today = $now->format( 'Y-m-d H' ) === $start->format( 'Y-m-d H' ); break; case 'week': /* translators: %s: first date of a week. */ $label = sprintf( __( 'Week of %s', 'abientot' ), $context->local_date( $start ) ); $short = Locale::date( 'j M', $start->getTimestamp(), $context->timezone ); $today = $now >= $start && $now < $start->modify( '+1 week' ); break; case 'month': $label = Locale::date( DateTimeHelper::month_year_format(), $start->getTimestamp(), $context->timezone ); $short = Locale::date( 'M', $start->getTimestamp(), $context->timezone ); $today = $now->format( 'Y-m' ) === $start->format( 'Y-m' ); break; default: $label = $context->local_date( $start, DateTimeHelper::full_date_format() ); $short = $start->format( 'j' ); $today = $now->format( 'Y-m-d' ) === $start->format( 'Y-m-d' ); } $known = isset( $previous[ $index ] ); $before = $previous[ $index ] ?? array( 'revenue_cents' => 0, 'booking_count' => 0 ); /* translators: 1: period label, 2: value this period, 3: value in the previous period. */ $tip = __( '%1$s: %2$s · previous period %3$s', 'abientot' ); $money[] = array( 'label' => $label, 'short' => $short, 'value' => $bucket['revenue_cents'], 'today' => $today, 'tip' => $known ? sprintf( $tip, $label, $context->money( $bucket['revenue_cents'] ), $context->money( $before['revenue_cents'] ) ) : $label . ': ' . $context->money( $bucket['revenue_cents'] ) ) + ( $known ? array( 'previous' => $before['revenue_cents'] ) : array() ); $count[] = array( 'label' => $label, 'short' => $short, 'value' => $bucket['booking_count'], 'today' => $today, 'tip' => $known ? sprintf( $tip, $label, ReportCharts::number( $bucket['booking_count'] ), ReportCharts::number( $before['booking_count'] ) ) : $label . ': ' . ReportCharts::number( $bucket['booking_count'] ) ) + ( $known ? array( 'previous' => $before['booking_count'] ) : array() ); $rows[] = array( $label, ReportCharts::number( $bucket['booking_count'] ), $context->money( $bucket['revenue_cents'] ), $known ? ReportCharts::number( $before['booking_count'] ) : '—', $known ? $context->money( $before['revenue_cents'] ) : '—' ); } $summary = $this->summary( $context ); ?>
$context->money_short( (float) $value ), __( 'Booking value by period, with the previous period as a dashed line', 'abientot' ), ReportCharts::SERIES[0] ), self::allowed_html() ); ?>
data( 'statuses', fn() => $context->insights->statuses( $context->scope() ) ); $summary = $this->summary( $context ); $total = max( 0, $summary['booking_count'] ); $segments = array(); $rows = array(); foreach ( $statuses as $index => $status ) { $label = (string) ( $status['label'] ?? '' ) ?: __( 'Unknown status', 'abientot' ); $segments[] = array( 'label' => $label, 'value' => (float) $status['booking_count'], 'color' => ReportCharts::entity_color( $status['color'] ?? '', $index ) ); $rows[] = array( $label, ReportCharts::number( $status['booking_count'] ), ReportCharts::percent( ReportCharts::ratio( (float) $status['booking_count'], (float) $total ) ) ); } $completed = ReportCharts::ratio( $summary['completed_count'], $total - $summary['cancelled_count'] ); echo '
' . wp_kses( ReportCharts::donut( $segments, ReportCharts::number( $total ), _n( 'booking', 'bookings', $total, 'abientot' ), __( 'Bookings by status', 'abientot' ) ), self::allowed_html() ); echo '
    '; foreach ( $segments as $index => $segment ) { echo '
  • ' . esc_html( $segment['label'] ) . '' . esc_html( $rows[ $index ][1] ) . '' . esc_html( $rows[ $index ][2] ) . '
  • '; } echo '
'; if ( $total > 0 ) { echo '
'; /* translators: %s: percentage of non-cancelled bookings marked completed. */ echo '

' . esc_html( sprintf( __( '%s of active bookings completed', 'abientot' ), ReportCharts::percent( $completed ) ) ) . '

'; if ( $summary['pending_count'] > 0 ) { /* translators: %s: number of bookings waiting for confirmation. */ echo '

' . esc_html( sprintf( _n( '%s booking still waiting for confirmation', '%s bookings still waiting for confirmation', $summary['pending_count'], 'abientot' ), ReportCharts::number( $summary['pending_count'] ) ) ) . '

'; } echo '
'; } echo wp_kses( ReportCharts::table( array( __( 'Status', 'abientot' ), __( 'Bookings', 'abientot' ), __( 'Share', 'abientot' ) ), $rows, __( 'Booking health', 'abientot' ) ), self::allowed_html() ); } public function card_services( ReportContext $context ): void { $services = $context->data( 'services', fn() => $context->insights->services( $context->scope() ) ); $by_value = array_sum( array_map( 'intval', array_column( $services, 'revenue_cents' ) ) ) > 0; $total = array_sum( array_map( 'intval', array_column( $services, $by_value ? 'revenue_cents' : 'booking_count' ) ) ); $rows = array(); $table = array(); $other = array( 'value' => 0, 'bookings' => 0 ); foreach ( $services as $index => $service ) { $value = (int) $service[ $by_value ? 'revenue_cents' : 'booking_count' ]; $name = (string) ( $service['name'] ?? '' ) ?: __( 'Unavailable historical resource', 'abientot' ); $table[] = array( $name, ReportCharts::number( $service['booking_count'] ), $context->money( (int) $service['revenue_cents'] ), ReportCharts::hours( (int) $service['booked_minutes'] ) ); if ( $index >= 6 ) { $other['value'] += $value; $other['bookings'] += (int) $service['booking_count']; continue; } /* translators: 1: number of bookings, 2: share of the total. */ $meta = sprintf( __( '%1$s bookings · %2$s of total', 'abientot' ), ReportCharts::number( $service['booking_count'] ), ReportCharts::percent( ReportCharts::ratio( $value, $total ) ) ); $rows[] = array( 'label' => $name, 'value' => $value, 'display' => $by_value ? $context->money( $value ) : ReportCharts::number( $value ), 'meta' => $meta, 'color' => ReportCharts::entity_color( $service['color'] ?? '', $index ) ); } if ( $other['bookings'] > 0 ) { $rows[] = array( 'label' => __( 'Other services', 'abientot' ), 'value' => $other['value'], 'display' => $by_value ? $context->money( $other['value'] ) : ReportCharts::number( $other['value'] ), /* translators: 1: number of bookings, 2: share of the total. */ 'meta' => sprintf( __( '%1$s bookings · %2$s of total', 'abientot' ), ReportCharts::number( $other['bookings'] ), ReportCharts::percent( ReportCharts::ratio( $other['value'], $total ) ) ), 'color' => '#91a6a2' ); } echo '
' . wp_kses( ReportCharts::hbars( $rows, __( 'Services ranked by booking value', 'abientot' ) ), self::allowed_html() ) . '
'; echo wp_kses( ReportCharts::table( array( __( 'Service', 'abientot' ), __( 'Bookings', 'abientot' ), __( 'Value', 'abientot' ), __( 'Booked time', 'abientot' ) ), $table, __( 'Top services', 'abientot' ) ), self::allowed_html() ); } public function card_busy( ReportContext $context ): void { $order = Locale::weekday_order(); $matrix = array_fill( 0, 7, array_fill( 0, 24, 0 ) ); foreach ( $this->hourly( $context ) as $row ) { $local = ( new DateTimeImmutable( $row['bucket'], new DateTimeZone( 'UTC' ) ) )->setTimezone( $context->timezone ); $matrix[ (int) $local->format( 'w' ) ][ (int) $local->format( 'G' ) ] += $row['active_count']; } $hours = array(); foreach ( $matrix as $weekday ) { foreach ( $weekday as $hour => $value ) { if ( $value > 0 ) { $hours[] = $hour; } } } if ( ! $hours ) { echo wp_kses( ReportCharts::empty_state(), self::allowed_html() ); return; } $first = min( $hours ); $last = max( max( $hours ), $first + 5 ); if ( $last > 23 ) { $first = max( 0, 23 - 5 ); $last = 23; } $clock = preg_match( '/[aA]/', DateTimeHelper::time_display_format() ) ? 'ga' : 'G'; $sunday = new DateTimeImmutable( '2024-01-07 12:00', $context->timezone ); $columns = array(); foreach ( range( $first, $last ) as $hour ) { $columns[] = Locale::date( $clock, $sunday->setTime( $hour, 0 )->getTimestamp(), $context->timezone ); } $rows = array(); $values = array(); $tips = array(); $table = array(); $peak = array( 'value' => 0, 'weekday' => 0, 'hour' => 0 ); $time = DateTimeHelper::time_display_format(); foreach ( $order as $row_index => $weekday ) { $day = $sunday->modify( '+' . $weekday . ' days' ); $rows[] = Locale::date( 'D', $day->getTimestamp(), $context->timezone ); $table_row = array( Locale::date( 'l', $day->getTimestamp(), $context->timezone ) ); foreach ( range( $first, $last ) as $column_index => $hour ) { $value = $matrix[ $weekday ][ $hour ]; $values[ $row_index ][ $column_index ] = $value; $from = Locale::date( $time, $day->setTime( $hour, 0 )->getTimestamp(), $context->timezone ); /* translators: 1: weekday, 2: start time, 3: number of bookings. */ $tips[ $row_index ][ $column_index ] = sprintf( _n( '%1$s %2$s: %3$s booking', '%1$s %2$s: %3$s bookings', $value, 'abientot' ), $table_row[0], $from, ReportCharts::number( $value ) ); $table_row[] = ReportCharts::number( $value ); if ( $value > $peak['value'] ) { $peak = array( 'value' => $value, 'label' => $table_row[0] . ' ' . $from ); } } $table[] = $table_row; } echo '
' . wp_kses( ReportCharts::heatmap( $rows, $columns, $values, $tips, __( 'Bookings by weekday and start hour', 'abientot' ) ), self::allowed_html() ); if ( $peak['value'] > 0 ) { $day_totals = array_map( 'array_sum', $values ); $busiest = array_keys( $day_totals, max( $day_totals ), true )[0]; echo '
'; /* translators: %s: weekday and time, e.g. Tuesday 10:00. */ echo '

' . esc_html( sprintf( __( 'Peak hour: %s', 'abientot' ), $peak['label'] ) ) . '

'; /* translators: %s: weekday. */ echo '

' . esc_html( sprintf( __( 'Busiest day: %s', 'abientot' ), $table[ $busiest ][0] ) ) . '

'; } echo '
' . wp_kses( ReportCharts::table( array_merge( array( __( 'Weekday', 'abientot' ) ), $columns ), $table, __( 'Busiest times', 'abientot' ) ), self::allowed_html() ); } public function card_booking( ReportContext $context ): void { $lead = $context->data( 'lead', fn() => $context->insights->lead_times( $context->scope() ) ); $sources = $context->data( 'sources', fn() => $context->insights->sources( $context->scope() ) ); $labels = array( 'same_day' => __( 'Same day', 'abientot' ), 'days_1_3' => __( '1–3 days', 'abientot' ), 'days_3_7' => __( '3–7 days', 'abientot' ), 'days_7_14' => __( '1–2 weeks', 'abientot' ), 'days_14_30' => __( '2–4 weeks', 'abientot' ), 'days_30_plus' => __( 'A month or more', 'abientot' ), ); $total = array_sum( array_intersect_key( $lead, $labels ) ); $columns = array(); $table = array(); foreach ( $labels as $key => $label ) { $value = (int) ( $lead[ $key ] ?? 0 ); $columns[] = array( 'label' => $label, 'short' => $label, 'value' => $value, 'tip' => $label . ': ' . ReportCharts::number( $value ) . ' · ' . ReportCharts::percent( ReportCharts::ratio( $value, $total ) ) ); $table[] = array( $label, ReportCharts::number( $value ), ReportCharts::percent( ReportCharts::ratio( $value, $total ) ) ); } echo '

' . esc_html__( 'How far ahead clients book', 'abientot' ) . '

'; if ( $total > 0 ) { $days = (int) ( $lead['average_minutes'] ?? 0 ) / 1440; /* translators: %s: average number of days. */ echo '' . esc_html( sprintf( __( 'Average %s days ahead', 'abientot' ), ReportCharts::number( $days, $days < 10 ? 1 : 0 ) ) ) . ''; } echo '
' . wp_kses( $total > 0 ? ReportCharts::columns( $columns, static fn( $value ): string => ReportCharts::number( $value ), __( 'Bookings by lead time', 'abientot' ), ReportCharts::SERIES[1], true ) : ReportCharts::empty_state(), self::allowed_html() ) . '
'; $names = array( 'online' => __( 'Booked online', 'abientot' ), 'team' => __( 'Added by the team', 'abientot' ), 'import' => __( 'Imported', 'abientot' ), 'other' => __( 'Other', 'abientot' ) ); $segments = array(); $index = 0; foreach ( $names as $key => $label ) { if ( $sources[ $key ] > 0 || in_array( $key, array( 'online', 'team' ), true ) ) { $segments[] = array( 'label' => $label, 'value' => (float) $sources[ $key ], 'display' => ReportCharts::number( $sources[ $key ] ), 'color' => ReportCharts::series( $index ) ); } ++$index; } echo '

' . esc_html__( 'Booking channel', 'abientot' ) . '

' . wp_kses( ReportCharts::stack( $segments, __( 'Bookings by channel', 'abientot' ) ), self::allowed_html() ) . '
'; foreach ( $segments as $segment ) { $table[] = array( $segment['label'], $segment['display'], ReportCharts::percent( ReportCharts::ratio( $segment['value'], array_sum( $sources ) ) ) ); } echo wp_kses( ReportCharts::table( array( __( 'Group', 'abientot' ), __( 'Bookings', 'abientot' ), __( 'Share', 'abientot' ) ), $table, __( 'Booking behaviour', 'abientot' ) ), self::allowed_html() ); } public function card_customers( ReportContext $context ): void { $now = $context->data( 'customers', fn() => $context->insights->customers( $context->scope() ) ); $before = $context->data( 'customers-previous', fn() => $context->insights->customers( $context->scope( true ) ) ); if ( $now['customer_count'] < 1 ) { echo wp_kses( ReportCharts::empty_state(), self::allowed_html() ); return; } $returning = max( 0, $now['customer_count'] - $now['new_count'] ); $stats = array( array( __( 'Customers', 'abientot' ), ReportCharts::number( $now['customer_count'] ), ReportCharts::delta( $now['customer_count'], $before['customer_count'] ) ), array( __( 'Repeat visitors', 'abientot' ), ReportCharts::percent( ReportCharts::ratio( $now['repeat_count'], $now['customer_count'] ) ), '' . esc_html__( 'Booked more than once', 'abientot' ) . '' ), array( __( 'Visits per customer', 'abientot' ), ReportCharts::number( ReportCharts::ratio( $now['booking_count'], $now['customer_count'] ), 1 ), '' . esc_html__( 'Active bookings only', 'abientot' ) . '' ), ); echo '
'; foreach ( $stats as $stat ) { echo '
' . esc_html( $stat[0] ) . '' . esc_html( $stat[1] ) . '' . wp_kses( $stat[2], self::allowed_html() ) . '
'; } echo '
' . wp_kses( ReportCharts::stack( array( array( 'label' => __( 'New customers', 'abientot' ), 'value' => (float) $now['new_count'], 'display' => ReportCharts::number( $now['new_count'] ), 'color' => ReportCharts::SERIES[0] ), array( 'label' => __( 'Returning customers', 'abientot' ), 'value' => (float) $returning, 'display' => ReportCharts::number( $returning ), 'color' => ReportCharts::SERIES[1] ), ), __( 'New and returning customers', 'abientot' ) ), self::allowed_html() ); } public function card_upcoming( ReportContext $context ): void { $today = new DateTimeImmutable( 'today', $context->timezone ); $rows = $context->data( 'upcoming', fn() => $context->insights->blocking_between( ReportContext::utc( $today ), ReportContext::utc( $today->modify( '+7 days' ) ) ) ); $days = array(); for ( $offset = 0; $offset < 7; ++$offset ) { $days[ $today->modify( '+' . $offset . ' days' )->format( 'Y-m-d' ) ] = array( 'count' => 0, 'minutes' => 0 ); } $pending = 0; foreach ( $rows as $row ) { $key = ( new DateTimeImmutable( (string) $row['start_utc'], new DateTimeZone( 'UTC' ) ) )->setTimezone( $context->timezone )->format( 'Y-m-d' ); if ( isset( $days[ $key ] ) ) { ++$days[ $key ]['count']; $days[ $key ]['minutes'] += (int) $row['duration_minutes']; } if ( 'pending' === ( $row['status_slug'] ?? '' ) ) { ++$pending; } } $columns = array(); foreach ( $days as $date => $day ) { $local = new DateTimeImmutable( $date, $context->timezone ); $label = $context->local_date( $local, DateTimeHelper::full_date_format() ); $columns[] = array( 'label' => $label, 'short' => 0 === count( $columns ) ? __( 'Today', 'abientot' ) : Locale::date( 'D', $local->getTimestamp(), $context->timezone ), 'value' => $day['count'], 'today' => 0 === count( $columns ), /* translators: 1: date, 2: number of bookings, 3: booked hours. */ 'tip' => sprintf( __( '%1$s: %2$s bookings · %3$s', 'abientot' ), $label, ReportCharts::number( $day['count'] ), ReportCharts::hours( $day['minutes'] ) ) ); } $total = array_sum( array_column( $days, 'count' ) ); echo '

' . esc_html( sprintf( /* translators: 1: number of bookings, 2: booked hours. */ _n( '%1$s booking · %2$s', '%1$s bookings · %2$s', $total, 'abientot' ), ReportCharts::number( $total ), ReportCharts::hours( array_sum( array_column( $days, 'minutes' ) ) ) ) ) . '

'; if ( $pending > 0 && current_user_can( 'abientot_manage_bookings' ) ) { /* translators: %s: number of bookings waiting for confirmation. */ echo '' . esc_html( sprintf( /* translators: %s: number of bookings waiting for confirmation. */ _n( '%s to confirm', '%s to confirm', $pending, 'abientot' ), ReportCharts::number( $pending ) ) ) . ''; } elseif ( $pending > 0 ) { echo '' . esc_html( sprintf( /* translators: %s: number of bookings waiting for confirmation. */ _n( '%s to confirm', '%s to confirm', $pending, 'abientot' ), ReportCharts::number( $pending ) ) ) . ''; } echo '
' . wp_kses( ReportCharts::columns( $columns, static fn( $value ): string => ReportCharts::number( $value ), __( 'Bookings per day for the next 7 days', 'abientot' ), ReportCharts::SERIES[0], true ), self::allowed_html() ) . '
'; // Names stay with people who manage bookings; shared viewers only see counts. if ( $context->owner && current_user_can( 'abientot_manage_bookings' ) ) { $next = $this->appointments->calendar_between( DateTimeHelper::now_utc_sql(), ReportContext::utc( $today->modify( '+7 days' ) ), array(), 5 ); if ( $next ) { echo ''; } if ( current_user_can( 'abientot_manage_bookings' ) ) { echo ''; } } } /* -------------------------------------------------------------- Setup */ /** * @return array */ private function setup_checklist(): array { $services = $this->services->list_for_admin(); $staff = $this->staff->default_staff(); $location = $this->locations->default_location(); $availability = $staff ? $this->availability->weekly_rules_for_staff( (int) $staff['id'] ) : array(); return array( array( 'label' => __( 'Add at least one active service', 'abientot' ), 'complete' => $this->has_active_service( $services ), 'url' => admin_url( 'admin.php?page=abientot-services' ), ), array( 'label' => __( 'Complete the default staff profile', 'abientot' ), 'complete' => $staff && '' !== trim( (string) ( $staff['display_name'] ?? '' ) ), 'url' => admin_url( 'admin.php?page=abientot-staff' ), ), array( 'label' => __( 'Confirm working hours', 'abientot' ), 'complete' => ! empty( $availability ), 'url' => admin_url( 'admin.php?page=abientot-staff' ), ), array( 'label' => __( 'Complete the default location', 'abientot' ), 'complete' => $location && '' !== trim( (string) ( $location['name'] ?? '' ) ), 'url' => admin_url( 'admin.php?page=abientot-locations' ), ), array( 'label' => __( 'Publish your booking page', 'abientot' ), 'complete' => (bool) ( new BookingPagesRepository() )->published( 1 ), 'url' => SetupWizard::url( 'page' ), ), array( 'label' => __( 'Make a test booking and check the email arrives', 'abientot' ), 'complete' => 'yes' === (string) SetupWizard::state()['email_confirmed'] || $this->has_public_booking(), 'url' => SetupWizard::url( 'test' ), ), ); } /** A booking made from the public form proves the page works, also on sites set up before the guided setup. */ private function has_public_booking(): bool { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One indexed existence check for the setup card. return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->prefix}abientot_appointments WHERE source = %s LIMIT 1", 'frontend' ) ); } /** * @param array> $services Services. */ private function has_active_service( array $services ): bool { foreach ( $services as $service ) { if ( 'active' === (string) ( $service['status'] ?? '' ) ) { return true; } } return false; } /** * @param array $items Checklist items. */ private function render_checklist( array $items ): void { $done = count( array_filter( array_column( $items, 'complete' ) ) ); echo wp_kses( ReportCharts::meter( $done / max( 1, count( $items ) ), __( 'Setup progress', 'abientot' ), 'mid' ), self::allowed_html() ); echo '
    '; foreach ( $items as $item ) { echo '
  • ' . esc_html( $item['label'] ) . '' . esc_html( $item['complete'] ? __( 'Complete', 'abientot' ) : __( 'To do', 'abientot' ) ) . '
  • '; } echo '
'; echo '

' . esc_html__( 'Open the guided setup', 'abientot' ) . '

'; } /** Chart helpers escape every value they print; this marks their output as trusted markup. */ /** * Tags and attributes that ReportCharts and the report cards produce (HTML and inline SVG). * * @return array> */ /** * Card bodies: the chart markup plus the buttons, attachment images and disclosure widgets * card renderers print. Render callbacks can come from the abientot_report_cards filter, so * their output is always filtered here. */ private static function card_html(): array { static $allowed = null; if ( null !== $allowed ) { return $allowed; } $allowed = self::allowed_html(); $common = $allowed['div']; foreach ( array( 'b', 'small', 'h3', 'h5', 'dl', 'dt', 'dd', 'ol', 'br', 'details', 'summary' ) as $tag ) { $allowed[ $tag ] = $common; } $allowed['details'] += array( 'open' => true ); $allowed['button'] = $common + array( 'type' => true, 'aria-pressed' => true, 'aria-expanded' => true, 'aria-controls' => true ); $allowed['img'] = $common + array( 'src' => true, 'srcset' => true, 'sizes' => true, 'alt' => true, 'width' => true, 'height' => true, 'loading' => true, 'decoding' => true, 'fetchpriority' => true ); return $allowed; } private static function allowed_html(): array { static $allowed = null; if ( null !== $allowed ) { return $allowed; } $common = array( 'class' => true, 'style' => true, 'role' => true, 'title' => true, 'hidden' => true, 'tabindex' => true, 'focusable' => true, 'aria-hidden' => true, 'aria-label' => true, 'aria-describedby' => true, 'data-*' => true ); $allowed = array(); foreach ( array( 'div', 'span', 'strong', 'em', 'i', 'p', 'ul', 'li', 'h4', 'time', 'a', 'table', 'caption', 'thead', 'tbody', 'tr', 'th', 'td', 'svg', 'g', 'polyline', 'polygon', 'circle', 'line', 'rect', 'path' ) as $tag ) { $allowed[ $tag ] = $common; } $allowed['a'] += array( 'href' => true ); $allowed['time'] += array( 'datetime' => true ); $allowed['th'] += array( 'scope' => true, 'colspan' => true ); $allowed['td'] += array( 'colspan' => true ); $allowed['svg'] += array( 'viewbox' => true, 'preserveaspectratio' => true, 'xmlns' => true, 'width' => true, 'height' => true ); $shape = array( 'points' => true, 'd' => true, 'r' => true, 'cx' => true, 'cy' => true, 'x' => true, 'y' => true, 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, 'width' => true, 'height' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-dasharray' => true, 'stroke-dashoffset' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'vector-effect' => true, 'transform' => true ); foreach ( array( 'g', 'polyline', 'polygon', 'circle', 'line', 'rect', 'path' ) as $tag ) { $allowed[ $tag ] += $shape; } return $allowed; } }