setTimezone( wp_timezone() )->format( 'Y-m-d H:i:s' ); } catch ( Exception ) { return null; } } // Fractional seconds on a wall-clock value carry nothing the column can hold. $value = (string) preg_replace( '/^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})\.\d+$/', '$1', $value ); $value = str_replace( 'T', ' ', $value ); foreach ( array( 'Y-m-d H:i:s', 'Y-m-d H:i', 'Y-m-d' ) as $format ) { $parsed = DateTimeImmutable::createFromFormat( '!' . $format, $value ); if ( false !== $parsed ) { return $parsed->format( 'Y-m-d H:i:s' ); } } return null; } /** * How far ahead of UTC the site is, right now, in seconds. * * ═══════════════════════════════════════════════════════════════════════════════════════════════ * FOR GROUPING A UTC COLUMN INTO THE READER'S DAYS, AND FOR NOTHING ELSE. * * Audit columns are written UTC (`current_time( 'mysql', true )`), so `DATE( created_at )` cuts * the day at UTC midnight. On a Tokyo site that is 9am local: everything between midnight and * 9am is filed under yesterday, and a daily chart is wrong for a third of every day. Adding this * offset before taking `DATE()` moves the cut to local midnight. * * WHY NOT `CONVERT_TZ`. It is the textbook answer and it returns NULL — silently, for every row * — on any MySQL whose timezone tables have not been loaded, which is the default on a stock * install and normal on shared hosting. A chart that renders empty on some hosts and not others * is a worse failure than the one being fixed. A bound integer of seconds works everywhere. * * THE LIMIT, STATED RATHER THAN HIDDEN: this is ONE offset, read once per request, applied to * every row. Over a range that crosses a daylight-saving boundary the rows on the far side are * bucketed an hour out, which can move a change near midnight into the neighbouring day. The * trade is deliberate: an hour of edge error twice a year, against hours of error every day. * * Not the same thing as `FilterCompiler::bound()`, which converts ONE instant and so can afford * to be DST-exact. This one has to be a constant inside a GROUP BY. * ═══════════════════════════════════════════════════════════════════════════════════════════════ */ public static function utc_offset(): int { return (int) wp_timezone()->getOffset( new \DateTimeImmutable( 'now', new \DateTimeZone( 'UTC' ) ) ); } /** * Is this a real Y-m-d date? Used to validate a calendar range's bounds. * * The round-trip comparison is the point: `createFromFormat` is lenient and rolls "2026-02-31" over to * March 3rd rather than failing, so a plain `false !== $parsed` would call an impossible date valid. * * @param string $value A Y-m-d date. */ public static function is_date( string $value ): bool { $parsed = DateTimeImmutable::createFromFormat( '!Y-m-d', $value ); return false !== $parsed && $parsed->format( 'Y-m-d' ) === $value; } }