| @@ -5,13 +5,21 @@ | ||
| 5 | 5 | * Converts the structured opening hours array into human-readable strings |
| 6 | 6 | * in three formats: compact (grouped), vertical (one day per line), and |
| 7 | 7 | * inline (single comma-separated line). |
| 8 | 8 | * |
| 9 | + * Times and day names are rendered through Receipt_Date_Formatter so a receipt | |
| 10 | + * uses a single convention source: the opening hours and the order timestamps | |
| 11 | + * share the same clock convention, day-period style, and locale. | |
| 12 | + * | |
| 9 | 13 | * @package WCPOS\WooCommercePOS\Services |
| 10 | 14 | */ |
| 11 | 15 | |
| 12 | 16 | namespace WCPOS\WooCommercePOS\Services; |
| 13 | 17 | |
| 18 | +use DateTimeImmutable; | |
| 19 | +use DateTimeZone; | |
| 20 | +use WCPOS\WooCommercePOS\i18n; | |
| 21 | + | |
| 14 | 22 | /** |
| 15 | 23 | * Opening_Hours_Formatter class. |
| 16 | 24 | */ |
| 17 | 25 | class Opening_Hours_Formatter { |
| @@ -23,17 +31,19 @@ | ||
| 23 | 31 | |
| 24 | 32 | /** |
| 25 | 33 | * Format as vertical list — one day per line, newline-separated. |
| 26 | 34 | * |
| 27 | - * @param array $hours Structured hours array (keys 0–6). | |
| 35 | + * @param array $hours Structured hours array (keys 0–6). | |
| 36 | + * @param string $locale Optional receipt locale; defaults to the site locale. | |
| 28 | 37 | * @return string Newline-separated string. |
| 29 | 38 | */ |
| 30 | - public static function format_vertical( array $hours ): string { | |
| 31 | - $lines = array(); | |
| 39 | + public static function format_vertical( array $hours, string $locale = '' ): string { | |
| 40 | + $closed = self::get_closed_label( $locale ); | |
| 41 | + $lines = array(); | |
| 32 | 42 | foreach ( self::DAY_KEYS as $day ) { |
| 33 | - $day_name = self::get_day_name( $day ); | |
| 43 | + $day_name = self::get_day_name( $day, $locale ); | |
| 34 | 44 | $slots = isset( $hours[ (string) $day ] ) ? $hours[ (string) $day ] : array(); |
| 35 | - $formatted = self::format_slots( $slots ); | |
| 45 | + $formatted = self::format_slots( $slots, $locale, $closed ); | |
| 36 | 46 | $lines[] = $day_name . ' ' . $formatted; |
| 37 | 47 | } |
| 38 | 48 | |
| 39 | 49 | return implode( "\n", $lines ); |
| @@ -41,17 +51,18 @@ | ||
| 41 | 51 | |
| 42 | 52 | /** |
| 43 | 53 | * Format as compact grouped — consecutive days with identical hours are ranged. |
| 44 | 54 | * |
| 45 | - * @param array $hours Structured hours array (keys 0–6). | |
| 55 | + * @param array $hours Structured hours array (keys 0–6). | |
| 56 | + * @param string $locale Optional receipt locale; defaults to the site locale. | |
| 46 | 57 | * @return string Newline-separated string. |
| 47 | 58 | */ |
| 48 | - public static function format_compact( array $hours ): string { | |
| 49 | - $groups = self::group_consecutive_days( $hours ); | |
| 59 | + public static function format_compact( array $hours, string $locale = '' ): string { | |
| 60 | + $groups = self::group_consecutive_days( $hours, $locale, self::get_closed_label( $locale ) ); | |
| 50 | 61 | $lines = array(); |
| 51 | 62 | |
| 52 | 63 | foreach ( $groups as $group ) { |
| 53 | - $day_label = self::format_day_range( $group['start'], $group['end'] ); | |
| 64 | + $day_label = self::format_day_range( $group['start'], $group['end'], $locale ); | |
| 54 | 65 | $lines[] = $day_label . ' ' . $group['formatted']; |
| 55 | 66 | } |
| 56 | 67 | |
| 57 | 68 | return implode( "\n", $lines ); |
| @@ -59,17 +70,18 @@ | ||
| 59 | 70 | |
| 60 | 71 | /** |
| 61 | 72 | * Format as inline — single comma-separated line using compact grouping. |
| 62 | 73 | * |
| 63 | - * @param array $hours Structured hours array (keys 0–6). | |
| 74 | + * @param array $hours Structured hours array (keys 0–6). | |
| 75 | + * @param string $locale Optional receipt locale; defaults to the site locale. | |
| 64 | 76 | * @return string Single line string. |
| 65 | 77 | */ |
| 66 | - public static function format_inline( array $hours ): string { | |
| 67 | - $groups = self::group_consecutive_days( $hours ); | |
| 78 | + public static function format_inline( array $hours, string $locale = '' ): string { | |
| 79 | + $groups = self::group_consecutive_days( $hours, $locale, self::get_closed_label( $locale ) ); | |
| 68 | 80 | $parts = array(); |
| 69 | 81 | |
| 70 | 82 | foreach ( $groups as $group ) { |
| 71 | - $day_label = self::format_day_range( $group['start'], $group['end'] ); | |
| 83 | + $day_label = self::format_day_range( $group['start'], $group['end'], $locale ); | |
| 72 | 84 | $parts[] = $day_label . ' ' . $group['formatted']; |
| 73 | 85 | } |
| 74 | 86 | |
| 75 | 87 | return implode( ', ', $parts ); |
| @@ -77,18 +89,20 @@ | ||
| 77 | 89 | |
| 78 | 90 | /** |
| 79 | 91 | * Group consecutive days that share identical time slots. |
| 80 | 92 | * |
| 81 | - * @param array $hours Structured hours array. | |
| 93 | + * @param array $hours Structured hours array. | |
| 94 | + * @param string $locale Receipt locale. | |
| 95 | + * @param string $closed Closed-day label, already resolved for the receipt locale. | |
| 82 | 96 | * @return array Array of groups, each with 'start', 'end', 'formatted'. |
| 83 | 97 | */ |
| 84 | - private static function group_consecutive_days( array $hours ): array { | |
| 98 | + private static function group_consecutive_days( array $hours, string $locale, string $closed ): array { | |
| 85 | 99 | $groups = array(); |
| 86 | 100 | $current = null; |
| 87 | 101 | |
| 88 | 102 | foreach ( self::DAY_KEYS as $day ) { |
| 89 | 103 | $slots = isset( $hours[ (string) $day ] ) ? $hours[ (string) $day ] : array(); |
| 90 | - $formatted = self::format_slots( $slots ); | |
| 104 | + $formatted = self::format_slots( $slots, $locale, $closed ); | |
| 91 | 105 | |
| 92 | 106 | if ( null === $current || $current['formatted'] !== $formatted ) { |
| 93 | 107 | if ( null !== $current ) { |
| 94 | 108 | $groups[] = $current; |
| @@ -102,8 +116,9 @@ | ||
| 102 | 116 | $current['end'] = $day; |
| 103 | 117 | } |
| 104 | 118 | } |
| 105 | 119 | |
| 120 | + // @phpstan-ignore notIdentical.alwaysTrue (belt-and-braces for empty input) | |
| 106 | 121 | if ( null !== $current ) { |
| 107 | 122 | $groups[] = $current; |
| 108 | 123 | } |
| 109 | 124 | |
| @@ -112,29 +127,32 @@ | ||
| 112 | 127 | |
| 113 | 128 | /** |
| 114 | 129 | * Format a day range label. |
| 115 | 130 | * |
| 116 | - * @param int $start Start day index (0–6). | |
| 117 | - * @param int $end End day index (0–6). | |
| 131 | + * @param int $start Start day index (0–6). | |
| 132 | + * @param int $end End day index (0–6). | |
| 133 | + * @param string $locale Receipt locale. | |
| 118 | 134 | * @return string |
| 119 | 135 | */ |
| 120 | - private static function format_day_range( int $start, int $end ): string { | |
| 136 | + private static function format_day_range( int $start, int $end, string $locale ): string { | |
| 121 | 137 | if ( $start === $end ) { |
| 122 | - return self::get_day_name( $start ); | |
| 138 | + return self::get_day_name( $start, $locale ); | |
| 123 | 139 | } |
| 124 | 140 | |
| 125 | - return self::get_day_name( $start ) . "\u{2013}" . self::get_day_name( $end ); | |
| 141 | + return self::get_day_name( $start, $locale ) . "\u{2013}" . self::get_day_name( $end, $locale ); | |
| 126 | 142 | } |
| 127 | 143 | |
| 128 | 144 | /** |
| 129 | 145 | * Format time slots for a single day. |
| 130 | 146 | * |
| 131 | - * @param array $slots Flat array of time pairs. | |
| 147 | + * @param array $slots Flat array of time pairs. | |
| 148 | + * @param string $locale Receipt locale. | |
| 149 | + * @param string $closed Closed-day label, already resolved for the receipt locale. | |
| 132 | 150 | * @return string |
| 133 | 151 | */ |
| 134 | - private static function format_slots( array $slots ): string { | |
| 152 | + private static function format_slots( array $slots, string $locale, string $closed ): string { | |
| 135 | 153 | if ( empty( $slots ) ) { |
| 136 | - return /* translators: Short WCPOS UI label; keep concise. */ __( 'Closed', 'woocommerce-pos' ); | |
| 154 | + return $closed; | |
| 137 | 155 | } |
| 138 | 156 | |
| 139 | 157 | // Drop trailing unpaired element to ensure open/close pairs. |
| 140 | 158 | if ( count( $slots ) % 2 !== 0 ) { |
| @@ -141,16 +159,16 @@ | ||
| 141 | 159 | array_pop( $slots ); |
| 142 | 160 | } |
| 143 | 161 | |
| 144 | 162 | if ( empty( $slots ) ) { |
| 145 | - return /* translators: Short WCPOS UI label; keep concise. */ __( 'Closed', 'woocommerce-pos' ); | |
| 163 | + return $closed; | |
| 146 | 164 | } |
| 147 | 165 | |
| 148 | 166 | $ranges = array(); |
| 149 | 167 | $slot_count = count( $slots ); |
| 150 | 168 | for ( $i = 0; $i < $slot_count - 1; $i += 2 ) { |
| 151 | - $open = self::format_time( $slots[ $i ] ); | |
| 152 | - $close = self::format_time( $slots[ $i + 1 ] ); | |
| 169 | + $open = self::format_time( $slots[ $i ], $locale ); | |
| 170 | + $close = self::format_time( $slots[ $i + 1 ], $locale ); | |
| 153 | 171 | $ranges[] = $open . " \u{2013} " . $close; |
| 154 | 172 | } |
| 155 | 173 | |
| 156 | 174 | return implode( ', ', $ranges ); |
| @@ -156,34 +174,65 @@ | ||
| 156 | 174 | return implode( ', ', $ranges ); |
| 157 | 175 | } |
| 158 | 176 | |
| 159 | 177 | /** |
| 160 | - * Format a time string according to WP time_format option. | |
| 178 | + * Resolve the closed-day label in the receipt locale. | |
| 161 | 179 | * |
| 162 | - * @param string $time Time in H:i format (e.g. "09:00"). | |
| 180 | + * The day names and times follow the receipt locale, so this label has to | |
| 181 | + * as well — otherwise a store whose locale differs from the site's renders | |
| 182 | + * a mixed-language line ("zo Closed"). Mirrors the locale guard in | |
| 183 | + * Receipt_I18n_Labels::get_labels(); resolve once per public call, because | |
| 184 | + * switch_to_locale() reloads the text domain. | |
| 185 | + * | |
| 186 | + * @param string $locale Receipt locale, or empty string for the site locale. | |
| 187 | + * @return string | |
| 188 | + */ | |
| 189 | + private static function get_closed_label( string $locale ): string { | |
| 190 | + if ( '' !== $locale && get_locale() !== $locale && function_exists( 'switch_to_locale' ) && switch_to_locale( $locale ) ) { | |
| 191 | + try { | |
| 192 | + new i18n(); | |
| 193 | + | |
| 194 | + return self::get_closed_label( '' ); | |
| 195 | + } finally { | |
| 196 | + restore_previous_locale(); | |
| 197 | + } | |
| 198 | + } | |
| 199 | + | |
| 200 | + return /* translators: Short WCPOS UI label; keep concise. */ __( 'Closed', 'woocommerce-pos' ); | |
| 201 | + } | |
| 202 | + | |
| 203 | + /** | |
| 204 | + * Format a wall-clock time through the shared receipt time renderer. | |
| 205 | + * | |
| 206 | + * The stored value is a bare wall clock with no date or zone, so it is | |
| 207 | + * anchored to a fixed UTC instant and rendered in UTC — the reference day | |
| 208 | + * exists only to give the formatter a timestamp. | |
| 209 | + * | |
| 210 | + * @param string $time Time in H:i format (e.g. "09:00"). | |
| 211 | + * @param string $locale Receipt locale. | |
| 163 | 212 | * @return string Formatted time (e.g. "9:00 AM" or "09:00"). |
| 164 | 213 | */ |
| 165 | - private static function format_time( string $time ): string { | |
| 166 | - $timestamp = strtotime( '2000-01-01 ' . $time ); | |
| 214 | + private static function format_time( string $time, string $locale ): string { | |
| 215 | + $timestamp = strtotime( '2000-01-01 ' . $time . ' UTC' ); | |
| 167 | 216 | |
| 168 | 217 | if ( false === $timestamp ) { |
| 169 | 218 | return $time; |
| 170 | 219 | } |
| 171 | 220 | |
| 172 | - $time_format = get_option( 'time_format', 'g:i A' ); | |
| 173 | - | |
| 174 | - return date_i18n( $time_format, $timestamp ); | |
| 221 | + return Receipt_Date_Formatter::time( $timestamp, new DateTimeZone( 'UTC' ), '' !== $locale ? $locale : null ); | |
| 175 | 222 | } |
| 176 | 223 | |
| 177 | 224 | /** |
| 178 | - * Get the localized short day name. | |
| 225 | + * Get the localized short day name through the shared receipt renderer. | |
| 179 | 226 | * |
| 180 | - * @param int $day Day index (0=Monday, 6=Sunday). | |
| 227 | + * @param int $day Day index (0=Monday, 6=Sunday). | |
| 228 | + * @param string $locale Receipt locale. | |
| 181 | 229 | * @return string Short day name (e.g. "Mon", "Tue"). |
| 182 | 230 | */ |
| 183 | - private static function get_day_name( int $day ): string { | |
| 231 | + private static function get_day_name( int $day, string $locale ): string { | |
| 184 | 232 | // 2024-01-01 is a Monday. Offset by $day to get the right weekday. |
| 185 | - $timestamp = strtotime( '2024-01-01 +' . $day . ' days' ); | |
| 233 | + $utc = new DateTimeZone( 'UTC' ); | |
| 234 | + $timestamp = ( new DateTimeImmutable( '2024-01-01 00:00:00', $utc ) )->modify( '+' . $day . ' days' )->getTimestamp(); | |
| 186 | 235 | |
| 187 | - return date_i18n( 'D', $timestamp ); | |
| 236 | + return Receipt_Date_Formatter::weekday_short( $timestamp, $utc, '' !== $locale ? $locale : null ); | |
| 188 | 237 | } |
| 189 | 238 | } |