arraypress/wp-date-utils

Dates for reporting: UTC ranges, presets, chart buckets and relative phrasing, plus the site timezone and formats.

Maintainers

Package info

github.com/arraypress/wp-date-utils

pkg:composer/arraypress/wp-date-utils

Transparency log

Statistics

Installs: 182

Dependents: 2

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-08-25 18:02 UTC

This package is auto-updated.

Last update: 2026-08-25 18:02:31 UTC


README

UTC-first date handling: a freezable clock, safe parsing, relative times, and reporting ranges with exclusive ends. Zero dependencies.

Why

Two things go wrong with dates in an application that reports on itself.

Tests that depend on the wall clock. A test asserting "this was 3 days ago" passes until it runs at midnight, or on the last day of a month, or during a DST transition. Inject a clock and freeze it.

Ranges with inclusive ends. WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31' silently drops everything on the 31st after midnight, because the end is 00:00:00. Every range here is half-open — >= from AND < to — which is the only form that composes without gaps or overlaps.

Features

  • 🕐 Freezable clockClock::frozen() makes time-dependent code testable.
  • 🌍 UTC throughout — store UTC, convert at the edge, never in between.
  • 📅 15 reporting presets — today through last year, each returning a half-open range.
  • 🔍 Safe parsing — returns null instead of throwing or guessing.
  • 💬 Relative phrasing — "3 hours ago", with an absolute timestamp for the tooltip.
  • 📊 Series queries — SQL for a bucketed chart, for SQLite and MySQL, with gap filling.
  • ↔️ Comparable periodsprevious() gives the immediately preceding window of the same length.

The site's clock

Everything above works in UTC, which is the only sane way to store a moment. Site turns one into what a person in the admin should see:

use ArrayPress\\Dates\\Site;

Site::format( $order->created_at );            // 'June 15, 2026' — the site's format
Site::format_datetime( $order->created_at );   // date and time, both site formats
Site::relative( $order->created_at );          // '3 hours ago'
Site::relative_with_exact( $order->created_at ); // ['text' => …, 'title' => …]

Two WordPress facts drive it: a site has a timezone that is not the server's, and formats an administrator chose. Reading either from PHP's defaults gives the wrong answer on most installs, and the symptom is an order that appears to have been placed an hour before it was.

A value that cannot be read comes back as an empty string, never as 1st January 1970 — an absence should not look like data.

Requirements

PHP 8.3+ and WordPress

Installation

composer require arraypress/wp-date-utils

Usage

use ArrayPress\Dates\{Clock, Preset, Timestamp};

$range = Preset::Last30Days->range();

$range->start();   // '2026-07-02 00:00:00'
$range->end();     // '2026-08-01 00:00:00' — exclusive
$range->days();    // 30

The clock

$clock = new Clock();                       // real time
$clock = Clock::frozen( '2026-08-01 12:00:00' );
$clock = Clock::frozen( 1785331200 );

$clock->now();   // unix seconds
$clock->utc();   // DateTimeImmutable in UTC

Every function that needs the current time takes an optional Clock. Pass a frozen one in tests and the assertions stop depending on when they run.

Preset::ThisMonth->range( $clock );
Timestamp::is_past( $expires_at, $clock );
Relative::format( $created_at, $clock );

Presets

Preset::Today, Yesterday, Last7Days, Last30Days, Last30DaysToDate, Last90Days,
       ThisWeek, LastWeek, ThisMonth, LastMonth, ThisQuarter, LastQuarter,
       ThisYear, LastYear, AllTime

Preset::options();                                    // value => label, for a select
Preset::resolve( 'custom', '2026-01-01', '2026-03-31' ); // named preset or explicit dates

resolve() is what a dashboard's date picker calls: it takes whatever came from the form and returns a Range either way.

Querying

[ $where, $bindings ] = $range->where( 'created_at' );

$sql = "SELECT SUM(total) FROM orders WHERE {$where}";
$statement->execute( $bindings );

Column names are validated against a strict pattern and throw if they don't match — they are interpolated, not bound, because SQL has no placeholder for an identifier.

For a chart:

use ArrayPress\Dates\Interval;

[ $sql, $bindings ] = $range->series_query(
    'orders',
    'created_at',
    Interval::Day,
    [ 'SUM(total) AS revenue' ]
);

$rows   = $db->query( $sql, $bindings );
$series = $range->fill( $rows, Interval::Day );  // every bucket present, gaps zeroed

fill() matters more than it looks: a chart built straight from grouped rows skips days with no orders, which turns a flat week into a misleading spike.

The $selects argument is interpolated into the statement. It is for aggregate expressions you wrote, never for anything that came from a request.

Comparisons

$this_month = Preset::ThisMonth->range();
$last_month = $this_month->previous();   // same length, immediately before

Relative times

use ArrayPress\Dates\Relative;

Relative::format( '2026-07-31 09:00:00' );        // '1 day ago'
Relative::with_title( '2026-07-31 09:00:00' );    // ['label' => …, 'title' => '… UTC']

The pair is for a UI: the phrase reads well, and the absolute timestamp goes in the title attribute so precision is one hover away.

Parsing and formatting

use ArrayPress\Dates\Timestamp;

Timestamp::parse( $value );      // ?DateTimeImmutable — null, not an exception
Timestamp::to_unix( $value );    // ?int
Timestamp::now();                // '2026-08-01 13:00:00'
Timestamp::iso( $value );        // '2026-08-01T13:00:00+00:00'
Timestamp::format( $value, 'j M Y', 'Europe/Dublin' );
Timestamp::is_past( $value );

parse() returning null rather than throwing is deliberate: a date column read from a database is routinely empty, malformed, or 0000-00-00, and none of those deserve an exception.

Storage

Store UTC. Convert on display, using the viewer's timezone, and never store the result.

A local timestamp cannot be reasoned about after the fact: you cannot tell whether 2026-10-25 01:30:00 in a European timezone was before or after the clocks changed, because both happened. UTC has no ambiguous hour.

Testing

composer install
composer test

84 tests, run entirely against a frozen clock — including the leap year, the DST boundaries, and the month-end arithmetic that breaks naive -1 month maths.

License

GPL-2.0-or-later