bool, 'ranges' => [ [ 'open' => 'HH:MM', 'close' => 'HH:MM' ], ... ] ] */ class Hours_Field { const NONCE_ACTION = 'mapfield_spots_save_hours'; const NONCE_NAME = 'mapfield_spots_hours_nonce'; public static function days(): array { return array( 'mon' => __( 'Monday', 'mapfield-spots' ), 'tue' => __( 'Tuesday', 'mapfield-spots' ), 'wed' => __( 'Wednesday', 'mapfield-spots' ), 'thu' => __( 'Thursday', 'mapfield-spots' ), 'fri' => __( 'Friday', 'mapfield-spots' ), 'sat' => __( 'Saturday', 'mapfield-spots' ), 'sun' => __( 'Sunday', 'mapfield-spots' ), ); } // Explicit translatable strings, not substr()'d from the full name — // abbreviation isn't a simple truncation in every language. public static function days_short(): array { return array( 'mon' => _x( 'Mon', 'day abbreviation', 'mapfield-spots' ), 'tue' => _x( 'Tue', 'day abbreviation', 'mapfield-spots' ), 'wed' => _x( 'Wed', 'day abbreviation', 'mapfield-spots' ), 'thu' => _x( 'Thu', 'day abbreviation', 'mapfield-spots' ), 'fri' => _x( 'Fri', 'day abbreviation', 'mapfield-spots' ), 'sat' => _x( 'Sat', 'day abbreviation', 'mapfield-spots' ), 'sun' => _x( 'Sun', 'day abbreviation', 'mapfield-spots' ), ); } public function __construct() { add_action( 'init', array( $this, 'register_meta' ), 20 ); add_action( 'add_meta_boxes', array( $this, 'add_meta_box' ) ); add_action( 'save_post', array( $this, 'save' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); } private function managed_post_types_with_hours(): array { $types = array(); foreach ( Map_Types::get_all() as $slug => $config ) { if ( ! empty( $config['fields']['hours'] ) ) { $types[] = CPT::post_type_slug( $slug ); } } return $types; } public function register_meta(): void { foreach ( Map_Types::get_all() as $slug => $config ) { register_post_meta( CPT::post_type_slug( $slug ), '_mapfield_spots_hours', array( 'type' => 'object', 'single' => true, 'show_in_rest' => false, // nested shape not worth a REST schema yet 'sanitize_callback' => array( self::class, 'sanitize_hours' ), 'auth_callback' => function () { return current_user_can( 'edit_posts' ); }, ) ); } } public static function sanitize_time( string $value ): string { return preg_match( '/^([01]\d|2[0-3]):[0-5]\d$/', $value ) ? $value : ''; } public static function sanitize_hours( $value ): array { $clean = array(); foreach ( array_keys( self::days() ) as $day ) { $entry = ( is_array( $value ) && isset( $value[ $day ] ) && is_array( $value[ $day ] ) ) ? $value[ $day ] : array(); $ranges = array(); if ( ! empty( $entry['ranges'] ) && is_array( $entry['ranges'] ) ) { foreach ( $entry['ranges'] as $range ) { $open = self::sanitize_time( is_array( $range ) ? (string) ( $range['open'] ?? '' ) : '' ); $close = self::sanitize_time( is_array( $range ) ? (string) ( $range['close'] ?? '' ) : '' ); if ( $open && $close ) { $ranges[] = array( 'open' => $open, 'close' => $close ); } } } $clean[ $day ] = array( 'closed' => ! empty( $entry['closed'] ), 'ranges' => $ranges, ); } return $clean; } // True once any day is set (closed or has a range) — keeps an untouched // field from rendering as a false "Mon–Sun: Closed" block on the front end. public static function has_any_data( array $hours ): bool { foreach ( $hours as $entry ) { if ( ! empty( $entry['closed'] ) || ! empty( $entry['ranges'] ) ) { return true; } } return false; } /** * Collapses consecutive days sharing the same hours (e.g. "Mon–Fri: 09:00–18:00"). * Each group: [ 'label' => 'Mon–Fri', 'text' => '09:00–18:00', 'is_today' => bool ] */ public static function grouped_for_display( array $hours ): array { $days = self::days_short(); $day_keys = array_keys( $days ); $today_key = $day_keys[ (int) current_time( 'N' ) - 1 ] ?? ''; $groups = array(); $current = null; foreach ( $day_keys as $day ) { $entry = $hours[ $day ] ?? array( 'closed' => true, 'ranges' => array() ); if ( $entry['closed'] || ! $entry['ranges'] ) { $text = __( 'Closed', 'mapfield-spots' ); } else { $parts = array(); foreach ( $entry['ranges'] as $range ) { $parts[] = $range['open'] . '–' . $range['close']; } $text = implode( ', ', $parts ); } if ( $current && $current['text'] === $text ) { $current['end'] = $day; $current['is_today'] = $current['is_today'] || ( $day === $today_key ); } else { if ( $current ) { $groups[] = $current; } $current = array( 'start' => $day, 'end' => $day, 'text' => $text, 'is_today' => ( $day === $today_key ) ); } } if ( $current ) { $groups[] = $current; } return array_map( function ( $group ) use ( $days ) { $label = $group['start'] === $group['end'] ? $days[ $group['start'] ] : $days[ $group['start'] ] . '–' . $days[ $group['end'] ]; return array( 'label' => $label, 'text' => $group['text'], 'is_today' => $group['is_today'] ); }, $groups ); } public function add_meta_box(): void { foreach ( Map_Types::get_all() as $slug => $config ) { if ( empty( $config['fields']['hours'] ) ) { continue; } // 'normal': admin.css pins this full-width (7-day table doesn't fit a half column). add_meta_box( 'mfs-hours', __( 'Opening hours', 'mapfield-spots' ), array( $this, 'render' ), CPT::post_type_slug( $slug ), 'normal', 'default' ); } } public function render( \WP_Post $post ): void { wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME ); $hours = self::sanitize_hours( get_post_meta( $post->ID, '_mapfield_spots_hours', true ) ); ?>