Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
97 / 97
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
DateTimeFormatter
100.00% covered (success)
100.00%
96 / 96
100.00% covered (success)
100.00%
9 / 9
61
100.00% covered (success)
100.00%
1 / 1
 format
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
14
 parse
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
6
 parseFromString
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
8
 formatRelative
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 formatRelativePolish
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 getPolishRelativeUnits
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 formatRelativeEnglish
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 getEnglishRelativeUnits
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
9
 pluralizePolish
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Engine\Application\Formatter;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use DateTimeImmutable;
12use DateTimeInterface;
13
14/**
15 * Universal Date and Time Formatter.
16 *
17 * Formats database datetime/date/time values into configured presentation formats:
18 * - datetime_seconds: YYYY-MM-DD HH:mm:ss (default)
19 * - datetime_short:   YYYY-MM-DD HH:mm
20 * - date_only:        YYYY-MM-DD
21 * - time_only:        HH:mm:ss
22 * - time_short:       HH:mm
23 * - relative:         Human-friendly relative time (e.g., '2h 15m ago' / '2 godz. temu')
24 * - full_microseconds: YYYY-MM-DD HH:mm:ss.uuuuuu
25 * - custom format:    Any valid PHP date format string
26 *
27 * @package App\Core\Engine\Application\Formatter
28 */
29final class DateTimeFormatter
30{
31    /** @var string Standard SQL datetime format. */
32    public const string SQL_DATETIME_FORMAT = 'Y-m-d H:i:s';
33
34    /** @var string Standard datetime with seconds. */
35    public const string FORMAT_DATETIME_SECONDS = 'datetime_seconds';
36
37    /** @var string Datetime without seconds. */
38    public const string FORMAT_DATETIME_SHORT = 'datetime_short';
39
40    /** @var string Date only format. */
41    public const string FORMAT_DATE_ONLY = 'date_only';
42
43    /** @var string Time only with seconds. */
44    public const string FORMAT_TIME_ONLY = 'time_only';
45
46    /** @var string Time only without seconds. */
47    public const string FORMAT_TIME_SHORT = 'time_short';
48
49    /** @var string Relative time representation. */
50    public const string FORMAT_RELATIVE = 'relative';
51
52    /** @var string Full datetime with microseconds. */
53    public const string FORMAT_FULL_MICROSECONDS = 'full_microseconds';
54
55    /**
56     * Formats a raw datetime value into the specified presentation format.
57     *
58     * @param mixed       $value   Raw database or timestamp value.
59     * @param string      $format  Desired display format mode or PHP format pattern.
60     * @param string      $locale  Target locale ('pl' or 'en').
61     * @param bool        $wrapTag Whether to wrap relative output in HTML <time> tag.
62     * @return string Formatted date string or empty string if invalid/null.
63     */
64    public static function format(
65        mixed  $value,
66        string $format = self::FORMAT_DATETIME_SECONDS,
67        string $locale = 'pl',
68        bool   $wrapTag = false,
69    ): string {
70        if ($value === null || $value === '' || $value === '0000-00-00 00:00:00' || $value === '0000-00-00') {
71            return '';
72        }
73
74        $date = self::parse($value);
75        if ($date === null) {
76            return (string) $value;
77        }
78
79        return match ($format) {
80            self::FORMAT_DATETIME_SECONDS => $date->format(self::SQL_DATETIME_FORMAT),
81            self::FORMAT_DATETIME_SHORT   => $date->format('Y-m-d H:i'),
82            self::FORMAT_DATE_ONLY        => $date->format('Y-m-d'),
83            self::FORMAT_TIME_ONLY        => $date->format('H:i:s'),
84            self::FORMAT_TIME_SHORT       => $date->format('H:i'),
85            self::FORMAT_FULL_MICROSECONDS => $date->format('Y-m-d H:i:s.u'),
86            self::FORMAT_RELATIVE, 'time_ago' => self::formatRelative($date, $locale, $wrapTag),
87            default                       => $date->format($format),
88        };
89    }
90
91    /**
92     * Parses various raw date representations into a DateTimeImmutable instance.
93     *
94     * @param mixed $value Raw timestamp, ISO string, SQL datetime with/without microseconds.
95     * @return DateTimeImmutable|null Parsed immutable datetime or null on failure.
96     */
97    public static function parse(mixed $value): ?DateTimeImmutable
98    {
99        if ($value instanceof DateTimeInterface) {
100            return DateTimeImmutable::createFromInterface($value);
101        }
102
103        if (is_int($value) || (is_string($value) && ctype_digit(trim($value)) && (int) trim($value) > 0)) {
104            return (new DateTimeImmutable())->setTimestamp((int) $value);
105        }
106
107        return self::parseFromString((string) $value);
108    }
109
110    /**
111     * Parses date string into DateTimeImmutable instance.
112     */
113    private static function parseFromString(string $raw): ?DateTimeImmutable
114    {
115        $str = trim($raw);
116        if ($str === '' || $str === '0000-00-00 00:00:00' || $str === '0000-00-00') {
117            return null;
118        }
119
120        $normalized = str_replace('T', ' ', $str);
121        $formats = ['Y-m-d H:i:s.u', self::SQL_DATETIME_FORMAT, 'Y-m-d H:i', 'Y-m-d'];
122        $result = null;
123        foreach ($formats as $fmt) {
124            $dt = DateTimeImmutable::createFromFormat($fmt, $normalized);
125            if ($dt !== false) {
126                $result = $dt;
127                break;
128            }
129        }
130
131        if ($result === null) {
132            try {
133                $result = new DateTimeImmutable($normalized);
134            } catch (\Throwable) {
135                $result = null;
136            }
137        }
138
139        return $result;
140    }
141
142    /**
143     * Formats a datetime into human-friendly relative time (e.g., '2 godz. temu').
144     *
145     * @param DateTimeInterface $date    The datetime to format.
146     * @param string            $locale  Target locale ('pl' or 'en').
147     * @param bool              $wrapTag Whether to wrap output in <time> HTML tag.
148     * @return string Relative time string.
149     */
150    public static function formatRelative(
151        DateTimeInterface $date,
152        string            $locale = 'pl',
153        bool              $wrapTag = false,
154    ): string {
155        $now       = new DateTimeImmutable();
156        $timestamp = $date->getTimestamp();
157        $diff      = $now->getTimestamp() - $timestamp;
158        $isFuture  = $diff < 0;
159        $absDiff   = abs($diff);
160
161        $text = $locale === 'pl'
162            ? self::formatRelativePolish($absDiff, $isFuture)
163            : self::formatRelativeEnglish($absDiff, $isFuture);
164
165        if (!$wrapTag) {
166            return $text;
167        }
168
169        $iso = $date->format(self::SQL_DATETIME_FORMAT);
170        return sprintf(
171            '<time datetime="%s" title="%s">%s</time>',
172            htmlspecialchars($iso, ENT_QUOTES, 'UTF-8'),
173            htmlspecialchars($iso, ENT_QUOTES, 'UTF-8'),
174            htmlspecialchars($text, ENT_QUOTES, 'UTF-8')
175        );
176    }
177
178    /**
179     * Generates Polish relative time expression.
180     *
181     * @param int  $diff     Absolute difference in seconds.
182     * @param bool $isFuture Whether date is in the future.
183     * @return string Polish relative string.
184     */
185    private static function formatRelativePolish(int $diff, bool $isFuture): string
186    {
187        if ($diff < 45) {
188            return $isFuture ? 'za chwilę' : 'przed chwilą';
189        }
190
191        [$amount, $suffix] = self::getPolishRelativeUnits($diff);
192        return $isFuture ? "za {$amount} {$suffix}" : "{$amount} {$suffix} temu";
193    }
194
195    /**
196     * Calculates Polish relative unit amount and suffix.
197     *
198     * @return array{0: int|string, 1: string}
199     */
200    private static function getPolishRelativeUnits(int $diff): array
201    {
202        if ($diff < 3600) {
203            $unit = [max(1, (int) round($diff / 60)), 'min'];
204        } elseif ($diff < 86400) {
205            $hours = (int) round($diff / 3600);
206            $unit = [$hours, self::pluralizePolish($hours, 'godzinę', 'godziny', 'godzin')];
207        } elseif ($diff < 2592000) {
208            $days = (int) round($diff / 86400);
209            $unit = [$days, $days === 1 ? 'dzień' : 'dni'];
210        } elseif ($diff < 31536000) {
211            $months = (int) round($diff / 2592000);
212            $unit = [$months, self::pluralizePolish($months, 'miesiąc', 'miesiące', 'miesięcy')];
213        } else {
214            $years = (int) round($diff / 31536000);
215            $unit = [$years, self::pluralizePolish($years, 'rok', 'lata', 'lat')];
216        }
217
218        return $unit;
219    }
220
221    /**
222     * Generates English relative time expression.
223     *
224     * @param int  $diff     Absolute difference in seconds.
225     * @param bool $isFuture Whether date is in the future.
226     * @return string English relative string.
227     */
228    private static function formatRelativeEnglish(int $diff, bool $isFuture): string
229    {
230        if ($diff < 45) {
231            return $isFuture ? 'in a moment' : 'just now';
232        }
233
234        [$amount, $unit] = self::getEnglishRelativeUnits($diff);
235        $spacing = $unit === 'm' ? '' : ' ';
236        return $isFuture ? "in {$amount}{$spacing}{$unit}" : "{$amount}{$spacing}{$unit} ago";
237    }
238
239    /**
240     * Calculates English relative unit amount and label.
241     *
242     * @return array{0: int|string, 1: string}
243     */
244    private static function getEnglishRelativeUnits(int $diff): array
245    {
246        if ($diff < 3600) {
247            $unit = [max(1, (int) round($diff / 60)), 'm'];
248        } elseif ($diff < 86400) {
249            $hours = (int) round($diff / 3600);
250            $unit = [$hours, $hours === 1 ? 'hour' : 'hours'];
251        } elseif ($diff < 2592000) {
252            $days = (int) round($diff / 86400);
253            $unit = [$days, $days === 1 ? 'day' : 'days'];
254        } elseif ($diff < 31536000) {
255            $months = (int) round($diff / 2592000);
256            $unit = [$months, $months === 1 ? 'month' : 'months'];
257        } else {
258            $years = (int) round($diff / 31536000);
259            $unit = [$years, $years === 1 ? 'year' : 'years'];
260        }
261
262        return $unit;
263    }
264
265    /**
266     * Handles Polish noun plural forms (1, 2-4, 5+).
267     *
268     * @param int    $number Number count.
269     * @param string $form1  Singular (np. godzina, rok).
270     * @param string $form2  Few 2-4 (np. godziny, lata).
271     * @param string $form5  Many 5+ (np. godzin, lat).
272     * @return string Correct noun form.
273     */
274    private static function pluralizePolish(int $number, string $form1, string $form2, string $form5): string
275    {
276        if ($number === 1) {
277            return $form1;
278        }
279
280        $mod10  = $number % 10;
281        $mod100 = $number % 100;
282
283        if ($mod10 >= 2 && $mod10 <= 4 && ($mod100 < 10 || $mod100 >= 20)) {
284            return $form2;
285        }
286
287        return $form5;
288    }
289}