Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.72% covered (warning)
83.72%
144 / 172
70.59% covered (warning)
70.59%
12 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
TermineFilterUtils
83.72% covered (warning)
83.72%
144 / 172
70.59% covered (warning)
70.59%
12 / 17
59.36
0.00% covered (danger)
0.00%
0 / 1
 loadTypeOptions
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 getDefaultFilter
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 isValidFilter
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
6
 getValidFilter
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getAllValidFiltersForSitemap
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getUiTypeFilterOptions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getUiDateRangeFilterOptions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getUiArchiveFilterOptions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getDateRangeOptions
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getSqlDateRangeFilter
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 getSqlTypeFilter
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
7.04
 getTitleFromFilter
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
9.01
 getDateFilterTitleYearSuffix
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getArchiveFilterTitleSuffix
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isFilterNotArchived
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getIsNotArchivedCriteria
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 fromEnv
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace Olz\Termine\Utils;
4
5use Doctrine\Common\Collections\Criteria;
6use Doctrine\Common\Collections\Expr\Comparison;
7use Olz\Entity\Termine\TerminLabel;
8use Olz\Utils\WithUtilsTrait;
9
10/**
11 * @phpstan-type Option array{ident: string, name: string, icon?: string}
12 * @phpstan-type UiOption array{selected: bool, new_filter: FullFilter, name: string, icon: ?string, ident: string}
13 * @phpstan-type FullFilter array{typ: string, datum: string, archiv: string}
14 * @phpstan-type PartialFilter array{typ?: string, datum?: string, archiv?: string}
15 */
16class TermineFilterUtils {
17    use WithUtilsTrait;
18
19    public const ARCHIVE_YEARS_THRESHOLD = 4;
20
21    public const ALL_ARCHIVE_OPTIONS = [
22        ['ident' => 'ohne', 'name' => "ohne Archiv"],
23        ['ident' => 'mit', 'name' => "mit Archiv"],
24    ];
25
26    /** @var array<Option> */
27    public array $allTypeOptions = [];
28
29    public function loadTypeOptions(): self {
30        $code_href = $this->envUtils()->getCodeHref();
31        $termin_label_repo = $this->entityManager()->getRepository(TerminLabel::class);
32        $termine_labels = $termin_label_repo->findBy(['on_off' => 1], ['position' => 'ASC']);
33        $this->allTypeOptions = [
34            [
35                'ident' => 'alle',
36                'name' => "Alle Termine",
37            ],
38            ...array_map(function ($label) use ($code_href) {
39                $ident = "{$label->getIdent()}";
40                $fallback_href = "{$code_href}assets/icns/termine_type_{$ident}_20.svg";
41                return [
42                    'ident' => $ident,
43                    'name' => "{$label->getName()}",
44                    'icon' => $label->getIcon() ? $label->getFileHref($label->getIcon()) : $fallback_href,
45                ];
46            }, $termine_labels),
47            [
48                'ident' => 'meldeschluss',
49                'name' => "Meldeschlüsse",
50                'icon' => "{$code_href}assets/icns/termine_type_meldeschluss_20.svg",
51            ],
52        ];
53        return $this;
54    }
55
56    /** @return FullFilter */
57    public function getDefaultFilter(): array {
58        return [
59            'typ' => 'alle',
60            'datum' => 'bevorstehend',
61            'archiv' => 'ohne',
62        ];
63    }
64
65    /** @param ?PartialFilter $filter */
66    public function isValidFilter(?array $filter): bool {
67        $has_correct_type = (
68            isset($filter['typ'])
69            && array_filter(
70                $this->allTypeOptions,
71                function ($type_option) use ($filter) {
72                    return $type_option['ident'] === $filter['typ'];
73                }
74            )
75        );
76        $has_correct_date_range = (
77            isset($filter['datum'])
78            && array_filter(
79                $this->getDateRangeOptions([...$this->getDefaultFilter(), ...$filter]),
80                function ($type_option) use ($filter) {
81                    return $type_option['ident'] === $filter['datum'];
82                }
83            )
84        );
85        $has_correct_archive = (
86            isset($filter['archiv'])
87            && array_filter(
88                TermineFilterUtils::ALL_ARCHIVE_OPTIONS,
89                function ($archive_option) use ($filter) {
90                    return $archive_option['ident'] === $filter['archiv'];
91                }
92            )
93        );
94        return $has_correct_type && $has_correct_date_range && $has_correct_archive;
95    }
96
97    /**
98     * @param ?PartialFilter $filter
99     *
100     * @return FullFilter
101     */
102    public function getValidFilter(?array $filter): array {
103        $default_filter = $this->getDefaultFilter();
104        if (!$filter) {
105            return $default_filter;
106        }
107        $merged_filter = [
108            'typ' => $filter['typ'] ?? $default_filter['typ'],
109            'datum' => $filter['datum'] ?? $default_filter['datum'],
110            'archiv' => $filter['archiv'] ?? $default_filter['archiv'],
111        ];
112        return $this->isValidFilter($merged_filter) ? $merged_filter : $default_filter;
113    }
114
115    /** @return array<FullFilter> */
116    public function getAllValidFiltersForSitemap(): array {
117        $all_valid_filters = [];
118        foreach ($this->allTypeOptions as $type_option) {
119            $date_range_options = $this->getDateRangeOptions($this->getValidFilter(['archiv' => 'ohne']));
120            foreach ($date_range_options as $date_range_option) {
121                $all_valid_filters[] = [
122                    'typ' => $type_option['ident'],
123                    'datum' => $date_range_option['ident'],
124                    'archiv' => 'ohne',
125                ];
126            }
127        }
128        return $all_valid_filters;
129    }
130
131    /**
132     * @param FullFilter $filter
133     *
134     * @return array<UiOption>
135     */
136    public function getUiTypeFilterOptions(array $filter): array {
137        return array_map(function ($type_option) use ($filter) {
138            $new_filter = $filter;
139            $new_filter['typ'] = $type_option['ident'];
140            return [
141                'selected' => $type_option['ident'] === $filter['typ'],
142                'new_filter' => $new_filter,
143                'name' => $type_option['name'],
144                'icon' => $type_option['icon'] ?? null,
145                'ident' => $type_option['ident'],
146            ];
147        }, $this->allTypeOptions);
148    }
149
150    /**
151     * @param FullFilter $filter
152     *
153     * @return array<UiOption>
154     */
155    public function getUiDateRangeFilterOptions(array $filter): array {
156        return array_map(function ($date_range_option) use ($filter) {
157            $new_filter = $filter;
158            $new_filter['datum'] = $date_range_option['ident'];
159            return [
160                'selected' => $date_range_option['ident'] === $filter['datum'],
161                'new_filter' => $new_filter,
162                'name' => $date_range_option['name'],
163                'icon' => null,
164                'ident' => $date_range_option['ident'],
165            ];
166        }, $this->getDateRangeOptions($filter));
167    }
168
169    /**
170     * @param FullFilter $filter
171     *
172     * @return array<UiOption>
173     */
174    public function getUiArchiveFilterOptions(array $filter): array {
175        return array_map(function ($archive_option) use ($filter) {
176            $new_filter = $filter;
177            $new_filter['archiv'] = $archive_option['ident'];
178            return [
179                'selected' => $archive_option['ident'] === $filter['archiv'],
180                'new_filter' => $new_filter,
181                'name' => $archive_option['name'],
182                'icon' => null,
183                'ident' => $archive_option['ident'],
184            ];
185        }, TermineFilterUtils::ALL_ARCHIVE_OPTIONS);
186    }
187
188    /**
189     * @param FullFilter $filter
190     *
191     * @return array<Option>
192     */
193    public function getDateRangeOptions(array $filter): array {
194        $include_archive = $filter['archiv'] === 'mit';
195        $current_year = intval($this->dateUtils()->getCurrentDateInFormat('Y'));
196        $first_year = $include_archive ? 2006 : $current_year - TermineFilterUtils::ARCHIVE_YEARS_THRESHOLD;
197        $options = [
198            ['ident' => 'bevorstehend', 'name' => "Bevorstehende"],
199        ];
200        for ($year = $current_year + 1; $year >= $first_year; $year--) {
201            $year_ident = strval($year);
202            $options[] = ['ident' => $year_ident, 'name' => $year_ident];
203        }
204        return $options;
205    }
206
207    /** @param PartialFilter $filter_arg */
208    public function getSqlDateRangeFilter(array $filter_arg, string $tbl = 't'): string {
209        if (!$this->isValidFilter($filter_arg)) {
210            return "'1'='0'";
211        }
212        $filter = $this->getValidFilter($filter_arg);
213        $today = $this->dateUtils()->getIsoToday();
214        if ($filter['datum'] === 'bevorstehend') {
215            return "({$tbl}.start_date >= '{$today}') OR ({$tbl}.end_date >= '{$today}')";
216        }
217        if (intval($filter['datum']) > 2000) {
218            $sane_year = strval(intval($filter['datum']));
219            return "YEAR({$tbl}.start_date) = '{$sane_year}'";
220        }
221        // @codeCoverageIgnoreStart
222        // Reason: Should not be reached.
223        return "'1' = '0'"; // invalid => show nothing
224        // @codeCoverageIgnoreEnd
225    }
226
227    /** @param PartialFilter $filter_arg */
228    public function getSqlTypeFilter(array $filter_arg, string $tbl = 't'): string {
229        if (!$this->isValidFilter($filter_arg)) {
230            return "'1'='0'";
231        }
232        $filter = $this->getValidFilter($filter_arg);
233        if ($filter['typ'] === 'alle') {
234            return "'1' = '1'";
235        }
236        if ($filter['typ'] === 'meldeschluss') {
237            return "{$tbl}.typ LIKE '%meldeschluss%'";
238        }
239        foreach ($this->allTypeOptions as $type_option) {
240            $ident = $type_option['ident'];
241            if ($filter['typ'] === $ident && preg_match('/^[a-zA-Z0-9_]+$/', $ident)) {
242                return "{$tbl}.typ LIKE '%{$ident}%'";
243            }
244        }
245        // @codeCoverageIgnoreStart
246        // Reason: Should not be reached.
247        return "'1' = '0'"; // invalid => show nothing
248        // @codeCoverageIgnoreEnd
249    }
250
251    /** @param FullFilter $filter */
252    public function getTitleFromFilter(array $filter): string {
253        if (!$this->isValidFilter($filter)) {
254            return "Termine";
255        }
256        $archive_title_suffix = $this->getArchiveFilterTitleSuffix($filter);
257        $year_suffix = $this->getDateFilterTitleYearSuffix($filter);
258        $is_upcoming = $filter['datum'] == 'bevorstehend';
259        if ($filter['typ'] === 'alle') {
260            if ($is_upcoming) {
261                return "Bevorstehende Termine";
262            }
263            return "Termine{$year_suffix}{$archive_title_suffix}";
264        }
265        if ($filter['typ'] === 'meldeschluss') {
266            if ($is_upcoming) {
267                return "Bevorstehende Meldeschlüsse";
268            }
269            return "Meldeschlüsse{$year_suffix}{$archive_title_suffix}";
270        }
271        foreach ($this->allTypeOptions as $type_option) {
272            if ($filter['typ'] === $type_option['ident']) {
273                $name = $type_option['name'];
274                if ($is_upcoming) {
275                    return "{$name} (bevorstehend)";
276                }
277                return "{$name}{$year_suffix}{$archive_title_suffix}";
278            }
279        }
280        // @codeCoverageIgnoreStart
281        // Reason: Should not be reached.
282        return "Termine{$archive_title_suffix}";
283        // @codeCoverageIgnoreEnd
284    }
285
286    /** @param FullFilter $filter */
287    private function getDateFilterTitleYearSuffix(array $filter): string {
288        if ($filter['datum'] == 'bevorstehend') {
289            return "";
290        }
291        if (intval($filter['datum']) < 2000) {
292            // @codeCoverageIgnoreStart
293            // Reason: Should not be reached.
294            // TODO: Logging
295            return "";
296            // @codeCoverageIgnoreEnd
297        }
298        $year = $filter['datum'];
299        return " {$year}";
300    }
301
302    /** @param FullFilter $filter */
303    private function getArchiveFilterTitleSuffix(array $filter): string {
304        if ($filter['archiv'] === 'mit') {
305            return " (Archiv)";
306        }
307        return "";
308    }
309
310    /** @param PartialFilter $filter */
311    public function isFilterNotArchived(array $filter): bool {
312        $valid_filter = $this->getValidFilter($filter);
313        return $valid_filter['archiv'] === 'ohne';
314    }
315
316    public function getIsNotArchivedCriteria(): Comparison {
317        $years_ago = intval($this->dateUtils()->getCurrentDateInFormat('Y')) - TermineFilterUtils::ARCHIVE_YEARS_THRESHOLD;
318        $beginning_of_years_ago = "{$years_ago}-01-01";
319        return Criteria::expr()->gte('start_date', new \DateTime($beginning_of_years_ago));
320    }
321
322    public static function fromEnv(): self {
323        return new self();
324    }
325}