substr($today, 0, 7)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- operator text: caught upstream and returned or stored as plain text, never printed as HTML. throw new \InvalidArgumentException(__('Choose a current or earlier calendar month.', 'wconvert'), (int) self::INVALID_MONTH); } $from = $month . '-01'; $end = (new \DateTimeImmutable($from, new \DateTimeZone('UTC')))->format('Y-m-t'); $to = min($end, self::daysBefore($today, 1)); // An empty first-day report has a valid date anchor, never yesterday's // counts disguised as this month's. days/covers/eachDay carry emptiness. return new self($from, max($from, $to), $to < $from); } /** * Two days the merchant chose, both counted (ADR 0132). * * **The days are typed, and today still comes from the server.** Ending * after `$today` is refused rather than clipped: a window that silently * ended earlier than asked is a window nobody chose. A range may END on * today — it is then live, and the route says so — and it may not be * longer than {@see self::MAX_DAYS}, the scan this table can afford. * * @throws \InvalidArgumentException with one of this class's codes. */ public static function between(string $from, string $to, string $today): self { if (!self::isDay($from) || !self::isDay($to)) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- operator text: caught upstream and returned or stored as plain text, never printed as HTML. throw new \InvalidArgumentException(__('Choose two calendar days.', 'wconvert'), (int) self::INVALID_DAY); } if ($from > $to) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- operator text: caught upstream and returned or stored as plain text, never printed as HTML. throw new \InvalidArgumentException(__('The first day comes after the last.', 'wconvert'), (int) self::REVERSED); } if ($to > $today) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- operator text: caught upstream and returned or stored as plain text, never printed as HTML. throw new \InvalidArgumentException(__('The last day cannot be after today.', 'wconvert'), (int) self::AFTER_TODAY); } $range = new self($from, $to); if ($range->days() > self::MAX_DAYS) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- operator text: caught upstream and returned or stored as plain text, never printed as HTML. throw new \InvalidArgumentException(__('Choose a shorter range.', 'wconvert'), (int) self::TOO_LONG); } return $range; } /** * The same number of days immediately before — for a rolling window, a * month and a custom range alike (ADR 0089). */ public function previous(): self { return self::lastDays($this->days(), self::daysBefore($this->from, 1)); } /** How many days the window covers, both ends counted. */ public function days(): int { if ($this->empty) return 0; $from = new \DateTimeImmutable($this->from, new \DateTimeZone('UTC')); $to = new \DateTimeImmutable($this->to, new \DateTimeZone('UTC')); return (int) $from->diff($to)->days + 1; } /** * Every day in the window, in order. * * The dashboard seeds its series with these so that a day nothing happened * on reports **0 rather than being absent**. A gap in a series reads as * missing data, and the quietest days are exactly the ones a merchant is * looking for. * * @return list */ public function eachDay(): array { $days = []; $day = new \DateTimeImmutable($this->from, new \DateTimeZone('UTC')); for ($i = 0, $count = $this->days(); $i < $count; $i++) { $days[] = $day->format(StatDay::FORMAT); $day = $day->modify('+1 day'); } return $days; } /** Whether a `stat_date` falls inside the window. */ public function covers(string $day): bool { return !$this->empty && $day >= $this->from && $day <= $this->to; } /** A real `Y-m-d` calendar day: the pattern alone would accept February 31. */ private static function isDay(string $day): bool { return preg_match('/^([0-9]{4})-([0-9]{2})-([0-9]{2})$/D', $day, $part) === 1 && checkdate((int) $part[2], (int) $part[3], (int) $part[1]); } /** * `$days` days before `$day`, on the calendar. * * UTC is named explicitly and means nothing: both ends are bare dates, so * the zone only decides which midnight the arithmetic happens at and every * zone gives the same answer. Naming one keeps PHP from reaching for the * `date_default_timezone_get()` of whatever server this runs on, which is * the one way this could differ between two installs. */ private static function daysBefore(string $day, int $days): string { return (new \DateTimeImmutable($day, new \DateTimeZone('UTC'))) ->modify("-{$days} days") ->format(StatDay::FORMAT); } }