1, '7d' => 7, '90d' => 90, 'all' => null, default => 30, }; } /** * Strict Y-m-d. '2026-2-3' and '2026-13-01' are rejected: PHP happily parses * both (the second rolls over into 2027), so the round-trip is the real check. */ public static function valid_date( string $date ): bool { $parsed = DateTimeImmutable::createFromFormat( '!Y-m-d', $date, new DateTimeZone( 'UTC' ) ); return $parsed instanceof DateTimeImmutable && $parsed->format( 'Y-m-d' ) === $date; } /** * The window a report reads: explicit from/to when given, else the range enum. * Never errors — callers (REST args, CLI) reject bad input before this point; * anything unusable here simply falls back to the range. * * With no from/to the result is exactly what range_start() returned before * Phase 13 and end stays null, so the SQL and the numbers are unchanged. * * @return array{start:?string, end:?string, days:?int} */ public static function resolve( string $range, ?string $from, ?string $to, int $now ): array { // A lone --from runs to today; a lone --to is meaningless, so it falls back. if ( null !== $from && self::valid_date( $from ) ) { $to = ( null !== $to && self::valid_date( $to ) ) ? $to : gmdate( 'Y-m-d', $now ); $start_ts = (int) strtotime( $from . ' 00:00:00 UTC' ); // End is exclusive — one day past `to`, so `to` itself is included. $end_ts = (int) strtotime( $to . ' 00:00:00 UTC' ) + self::DAY; return array( 'start' => gmdate( 'Y-m-d H:i:s', $start_ts ), 'end' => gmdate( 'Y-m-d H:i:s', $end_ts ), 'days' => max( 1, (int) round( ( $end_ts - $start_ts ) / self::DAY ) ), ); } return array( 'start' => self::range_start( $range, $now ), 'end' => null, 'days' => self::days( $range ), ); } /** Midnight of today−(N−1); null = lifetime. */ public static function range_start( string $range, int $now ): ?string { $days = self::days( $range ); return null === $days ? null : gmdate( 'Y-m-d 00:00:00', $now - ( $days - 1 ) * self::DAY ); } /** * The immediately preceding window of equal length: [start − days, start). * Whole days subtracted from a midnight, so month lengths and leap years are * arithmetic, not special cases. Lifetime has nothing to compare against. */ public static function previous( array $window ): ?array { if ( null === ( $window['days'] ?? null ) || null === ( $window['start'] ?? null ) ) { return null; } $start = (int) strtotime( $window['start'] . ' UTC' ); return array( 'start' => gmdate( 'Y-m-d H:i:s', $start - $window['days'] * self::DAY ), 'end' => $window['start'], 'days' => $window['days'], ); } /** A one-day window charts hourly; everything longer charts daily. */ public static function interval( array $window ): string { return 1 === ( $window['days'] ?? null ) ? 'hour' : 'day'; } /** * Buckets to draw. Hourly stops at the hour we are in — a window covering * today must never render future hours as zeroes. Lifetime charts 90 days * (its KPIs stay lifetime). */ public static function buckets( array $window, string $interval, int $now ): int { $days = $window['days'] ?? 90; if ( 'hour' !== $interval ) { return $days; } $start = (int) strtotime( (string) $window['start'] . ' UTC' ); $elapsed = (int) floor( ( $now - $start ) / self::HOUR ) + 1; return max( 1, min( 24 * $days, $elapsed ) ); } /** Fractional change; null when there is no previous value to divide by. */ public static function delta( int|float $current, int|float $previous ): ?float { if ( ! $previous ) { return null; } return round( ( $current - $previous ) / $previous, 4 ); } /** * The window's WHERE fragment for one datetime column (moved here from * Saddle_Analytics_Report in 17b — both read-path classes need it, and it is pure * string-building). The upper bound is emitted only when the window has * one — with a plain range enum this returns exactly the * ' AND