xref: /webtrees/app/Date/AbstractCalendarDate.php (revision 202c018b592d5a516e4a465dc6dc515f3be37399)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2023 webtrees development team
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Date;
21
22use Fisharebest\ExtCalendar\CalendarInterface;
23use Fisharebest\ExtCalendar\JewishCalendar;
24use Fisharebest\Webtrees\Http\RequestHandlers\CalendarPage;
25use Fisharebest\Webtrees\I18N;
26use Fisharebest\Webtrees\Registry;
27use Fisharebest\Webtrees\Tree;
28use InvalidArgumentException;
29
30use function get_class;
31use function intdiv;
32use function is_array;
33use function is_int;
34use function preg_match;
35use function route;
36use function sprintf;
37use function str_contains;
38use function strpbrk;
39use function strtr;
40use function trim;
41
42/**
43 * Classes for Gedcom Date/Calendar functionality.
44 *
45 * CalendarDate is a base class for classes such as GregorianDate, etc.
46 * + All supported calendars have non-zero days/months/years.
47 * + We store dates as both Y/M/D and Julian Days.
48 * + For imprecise dates such as "JAN 2000" we store the start/end julian day.
49 *
50 * NOTE: Since different calendars start their days at different times, (civil
51 * midnight, solar midnight, sunset, sunrise, etc.), we convert on the basis of
52 * midday.
53 */
54abstract class AbstractCalendarDate
55{
56    // GEDCOM calendar escape
57    public const ESCAPE = '@#DUNKNOWN@';
58
59    // Convert GEDCOM month names to month numbers.
60    protected const MONTH_TO_NUMBER = [];
61    protected const NUMBER_TO_MONTH = [];
62
63    protected CalendarInterface $calendar;
64
65    public int $year;
66
67    public int $month;
68
69    public int $day;
70
71    private int $minimum_julian_day;
72
73    private int $maximum_julian_day;
74
75    /**
76     * Create a date from either:
77     * a Julian day number
78     * day/month/year strings from a GEDCOM date
79     * another CalendarDate object
80     *
81     * @param array<string>|int|AbstractCalendarDate $date
82     */
83    public function __construct(array|int|AbstractCalendarDate $date)
84    {
85        // Construct from an integer (a julian day number)
86        if (is_int($date)) {
87            $this->minimum_julian_day = $date;
88            $this->maximum_julian_day = $date;
89            [$this->year, $this->month, $this->day] = $this->calendar->jdToYmd($date);
90
91            return;
92        }
93
94        // Construct from an array (of three gedcom-style strings: "1900", "FEB", "4")
95        if (is_array($date)) {
96            $this->day = (int) $date[2];
97            $this->month = static::MONTH_TO_NUMBER[$date[1]] ?? 0;
98
99            if ($this->month === 0) {
100                $this->day   = 0;
101            }
102
103            $this->year = $this->extractYear($date[0]);
104
105            // Our simple lookup table above does not take into account Adar and leap-years.
106            if ($this->month === 6 && $this->calendar instanceof JewishCalendar && !$this->calendar->isLeapYear($this->year)) {
107                $this->month = 7;
108            }
109
110            $this->setJdFromYmd();
111
112            return;
113        }
114
115        // Construct from a CalendarDate
116        $this->minimum_julian_day = $date->minimum_julian_day;
117        $this->maximum_julian_day = $date->maximum_julian_day;
118
119        // Construct from an equivalent xxxxDate object
120        if (get_class($this) === get_class($date)) {
121            $this->year  = $date->year;
122            $this->month = $date->month;
123            $this->day   = $date->day;
124
125            return;
126        }
127
128        // Not all dates can be converted
129        if (!$this->inValidRange()) {
130            $this->year  = 0;
131            $this->month = 0;
132            $this->day   = 0;
133
134            return;
135        }
136
137        // ...else construct an inequivalent xxxxDate object
138        if ($date->year === 0) {
139            // Incomplete date - convert on basis of anniversary in current year
140            $today = $date->calendar->jdToYmd(Registry::timestampFactory()->now()->julianDay());
141            $jd    = $date->calendar->ymdToJd($today[0], $date->month, $date->day === 0 ? $today[2] : $date->day);
142        } else {
143            // Complete date
144            $jd = intdiv($date->maximum_julian_day + $date->minimum_julian_day, 2);
145        }
146        [$this->year, $this->month, $this->day] = $this->calendar->jdToYmd($jd);
147        // New date has same precision as original date
148        if ($date->year === 0) {
149            $this->year = 0;
150        }
151        if ($date->month === 0) {
152            $this->month = 0;
153        }
154        if ($date->day === 0) {
155            $this->day = 0;
156        }
157        $this->setJdFromYmd();
158    }
159
160    /**
161     * @return int
162     */
163    public function maximumJulianDay(): int
164    {
165        return $this->maximum_julian_day;
166    }
167
168    /**
169     * @return int
170     */
171    public function year(): int
172    {
173        return $this->year;
174    }
175
176    /**
177     * @return int
178     */
179    public function month(): int
180    {
181        return $this->month;
182    }
183
184    /**
185     * @return int
186     */
187    public function day(): int
188    {
189        return $this->day;
190    }
191
192    /**
193     * @return int
194     */
195    public function minimumJulianDay(): int
196    {
197        return $this->minimum_julian_day;
198    }
199
200    /**
201     * Is the current year a leap year?
202     *
203     * @return bool
204     */
205    public function isLeapYear(): bool
206    {
207        return $this->calendar->isLeapYear($this->year);
208    }
209
210    /**
211     * Set the object’s Julian day number from a potentially incomplete year/month/day
212     *
213     * @return void
214     */
215    public function setJdFromYmd(): void
216    {
217        if ($this->year === 0) {
218            $this->minimum_julian_day = 0;
219            $this->maximum_julian_day = 0;
220        } elseif ($this->month === 0) {
221            $this->minimum_julian_day = $this->calendar->ymdToJd($this->year, 1, 1);
222            $this->maximum_julian_day = $this->calendar->ymdToJd($this->nextYear($this->year), 1, 1) - 1;
223        } elseif ($this->day === 0) {
224            [$ny, $nm] = $this->nextMonth();
225            $this->minimum_julian_day = $this->calendar->ymdToJd($this->year, $this->month, 1);
226            $this->maximum_julian_day = $this->calendar->ymdToJd($ny, $nm, 1) - 1;
227        } else {
228            $this->minimum_julian_day = $this->calendar->ymdToJd($this->year, $this->month, $this->day);
229            $this->maximum_julian_day = $this->minimum_julian_day;
230        }
231    }
232
233    /**
234     * Full day of the week
235     *
236     * @param int $day_number
237     *
238     * @return string
239     */
240    public function dayNames(int $day_number): string
241    {
242        static $translated_day_names;
243
244        if ($translated_day_names === null) {
245            $translated_day_names = [
246                0 => I18N::translate('Monday'),
247                1 => I18N::translate('Tuesday'),
248                2 => I18N::translate('Wednesday'),
249                3 => I18N::translate('Thursday'),
250                4 => I18N::translate('Friday'),
251                5 => I18N::translate('Saturday'),
252                6 => I18N::translate('Sunday'),
253            ];
254        }
255
256        return $translated_day_names[$day_number];
257    }
258
259    /**
260     * Abbreviated day of the week
261     *
262     * @param int $day_number
263     *
264     * @return string
265     */
266    protected function dayNamesAbbreviated(int $day_number): string
267    {
268        static $translated_day_names;
269
270        if ($translated_day_names === null) {
271            $translated_day_names = [
272                /* I18N: abbreviation for Monday */
273                0 => I18N::translate('Mon'),
274                /* I18N: abbreviation for Tuesday */
275                1 => I18N::translate('Tue'),
276                /* I18N: abbreviation for Wednesday */
277                2 => I18N::translate('Wed'),
278                /* I18N: abbreviation for Thursday */
279                3 => I18N::translate('Thu'),
280                /* I18N: abbreviation for Friday */
281                4 => I18N::translate('Fri'),
282                /* I18N: abbreviation for Saturday */
283                5 => I18N::translate('Sat'),
284                /* I18N: abbreviation for Sunday */
285                6 => I18N::translate('Sun'),
286            ];
287        }
288
289        return $translated_day_names[$day_number];
290    }
291
292    /**
293     * Most years are 1 more than the previous, but not always (e.g. 1BC->1AD)
294     *
295     * @param int $year
296     *
297     * @return int
298     */
299    protected function nextYear(int $year): int
300    {
301        return $year + 1;
302    }
303
304    /**
305     * Calendars that use suffixes, etc. (e.g. “B.C.”) or OS/NS notation should redefine this.
306     *
307     * @param string $year
308     *
309     * @return int
310     */
311    protected function extractYear(string $year): int
312    {
313        return (int) $year;
314    }
315
316    /**
317     * Compare two dates, for sorting
318     *
319     * @param AbstractCalendarDate $d1
320     * @param AbstractCalendarDate $d2
321     *
322     * @return int
323     */
324    public static function compare(AbstractCalendarDate $d1, AbstractCalendarDate $d2): int
325    {
326        if ($d1->maximum_julian_day < $d2->minimum_julian_day) {
327            return -1;
328        }
329
330        if ($d2->maximum_julian_day < $d1->minimum_julian_day) {
331            return 1;
332        }
333
334        return 0;
335    }
336
337    /**
338     * Calculate the years/months/days between this date and another date.
339     * Results assume you add the days first, then the months.
340     * 4 February -> 3 July is 27 days (3 March) and 4 months.
341     * It is not 4 months (4 June) and 29 days.
342     *
343     * @param AbstractCalendarDate $date
344     *
345     * @return array<int> Age in years/months/days
346     */
347    public function ageDifference(AbstractCalendarDate $date): array
348    {
349        // Incomplete dates
350        if ($this->year === 0 || $date->year === 0) {
351            return [-1, -1, -1];
352        }
353
354        // Overlapping dates
355        if (self::compare($this, $date) === 0) {
356            return [0, 0, 0];
357        }
358
359        // Perform all calculations using the calendar of the first date
360        [$year1, $month1, $day1] = $this->calendar->jdToYmd($this->minimum_julian_day);
361        [$year2, $month2, $day2] = $this->calendar->jdToYmd($date->minimum_julian_day);
362
363        $years  = $year2 - $year1;
364        $months = $month2 - $month1;
365        $days   = $day2 - $day1;
366
367        if ($days < 0) {
368            $days += $this->calendar->daysInMonth($year1, $month1);
369            $months--;
370        }
371
372        if ($months < 0) {
373            $months += $this->calendar->monthsInYear($year2);
374            $years--;
375        }
376
377        return [$years, $months, $days];
378    }
379
380    /**
381     * Convert a date from one calendar to another.
382     *
383     * @param string $calendar
384     *
385     * @return AbstractCalendarDate
386     */
387    public function convertToCalendar(string $calendar): AbstractCalendarDate
388    {
389        switch ($calendar) {
390            case 'gregorian':
391                return new GregorianDate($this);
392            case 'julian':
393                return new JulianDate($this);
394            case 'jewish':
395                return new JewishDate($this);
396            case 'french':
397                return new FrenchDate($this);
398            case 'hijri':
399                return new HijriDate($this);
400            case 'jalali':
401                return new JalaliDate($this);
402            default:
403                return $this;
404        }
405    }
406
407    /**
408     * Is this date within the valid range of the calendar
409     *
410     * @return bool
411     */
412    public function inValidRange(): bool
413    {
414        return $this->minimum_julian_day >= $this->calendar->jdStart() && $this->maximum_julian_day <= $this->calendar->jdEnd();
415    }
416
417    /**
418     * How many months in a year
419     *
420     * @return int
421     */
422    public function monthsInYear(): int
423    {
424        return $this->calendar->monthsInYear();
425    }
426
427    /**
428     * How many days in the current month
429     *
430     * @return int
431     */
432    public function daysInMonth(): int
433    {
434        try {
435            return $this->calendar->daysInMonth($this->year, $this->month);
436        } catch (InvalidArgumentException) {
437            // calendar.php calls this with "DD MMM" dates, for which we cannot calculate
438            // the length of a month. Should we validate this before calling this function?
439            return 0;
440        }
441    }
442
443    /**
444     * How many days in the current week
445     *
446     * @return int
447     */
448    public function daysInWeek(): int
449    {
450        return $this->calendar->daysInWeek();
451    }
452
453    /**
454     * Format a date, using similar codes to the PHP date() function.
455     *
456     * @param string $format    See https://php.net/date
457     * @param string $qualifier GEDCOM qualifier, so we can choose the right case for the month name.
458     *
459     * @return string
460     */
461    public function format(string $format, string $qualifier = ''): string
462    {
463        // Dates can include additional punctuation and symbols. e.g.
464        // %F %j, %Y
465        // %Y. %F %d.
466        // %Y年 %n月 %j日
467        // %j. %F %Y
468        // Don’t show exact details or unnecessary punctuation for inexact dates.
469        if ($this->day === 0) {
470            $format = strtr($format, ['%d' => '', '日' => '', '%j,' => '', '%j' => '', '%l' => '', '%D' => '', '%N' => '', '%S' => '', '%w' => '', '%z' => '']);
471        }
472        if ($this->month === 0) {
473            $format = strtr($format, ['%F' => '', '%m' => '', '%M' => '', '月' => '', '%n' => '', '%t' => '']);
474        }
475        if ($this->year === 0) {
476            $format = strtr($format, ['%t' => '', '%L' => '', '%G' => '', '%y' => '', '年' => '', '%Y' => '']);
477        }// 年 %n月%j日
478        $format = trim($format, ',. /-');
479
480        if ($this->day !== 0 && preg_match('/%[djlDNSwz]/', $format)) {
481            // If we have a day-number *and* we are being asked to display it, then genitive
482            $case = 'GENITIVE';
483        } else {
484            switch ($qualifier) {
485                case 'TO':
486                case 'ABT':
487                case 'FROM':
488                    $case = 'GENITIVE';
489                    break;
490                case 'AFT':
491                    $case = 'LOCATIVE';
492                    break;
493                case 'BEF':
494                case 'BET':
495                case 'AND':
496                    $case = 'INSTRUMENTAL';
497                    break;
498                case '':
499                case 'INT':
500                case 'EST':
501                case 'CAL':
502                default: // There shouldn't be any other options...
503                    $case = 'NOMINATIVE';
504                    break;
505            }
506        }
507        // Build up the formatted date, character at a time
508        if (str_contains($format, '%d')) {
509            $format = strtr($format, ['%d' => $this->formatDayZeros()]);
510        }
511        if (str_contains($format, '%j')) {
512            $format = strtr($format, ['%j' => $this->formatDay()]);
513        }
514        if (str_contains($format, '%l')) {
515            $format = strtr($format, ['%l' => $this->formatLongWeekday()]);
516        }
517        if (str_contains($format, '%D')) {
518            $format = strtr($format, ['%D' => $this->formatShortWeekday()]);
519        }
520        if (str_contains($format, '%N')) {
521            $format = strtr($format, ['%N' => $this->formatIsoWeekday()]);
522        }
523        if (str_contains($format, '%w')) {
524            $format = strtr($format, ['%w' => $this->formatNumericWeekday()]);
525        }
526        if (str_contains($format, '%z')) {
527            $format = strtr($format, ['%z' => $this->formatDayOfYear()]);
528        }
529        if (str_contains($format, '%F')) {
530            $format = strtr($format, ['%F' => $this->formatLongMonth($case)]);
531        }
532        if (str_contains($format, '%m')) {
533            $format = strtr($format, ['%m' => $this->formatMonthZeros()]);
534        }
535        if (str_contains($format, '%M')) {
536            $format = strtr($format, ['%M' => $this->formatShortMonth()]);
537        }
538        if (str_contains($format, '%n')) {
539            $format = strtr($format, ['%n' => $this->formatMonth()]);
540        }
541        if (str_contains($format, '%t')) {
542            $format = strtr($format, ['%t' => (string) $this->daysInMonth()]);
543        }
544        if (str_contains($format, '%L')) {
545            $format = strtr($format, ['%L' => $this->isLeapYear() ? '1' : '0']);
546        }
547        if (str_contains($format, '%Y')) {
548            $format = strtr($format, ['%Y' => $this->formatLongYear()]);
549        }
550        if (str_contains($format, '%y')) {
551            $format = strtr($format, ['%y' => $this->formatShortYear()]);
552        }
553        // These 4 extensions are useful for re-formatting gedcom dates.
554        if (str_contains($format, '%@')) {
555            $format = strtr($format, ['%@' => $this->formatGedcomCalendarEscape()]);
556        }
557        if (str_contains($format, '%A')) {
558            $format = strtr($format, ['%A' => $this->formatGedcomDay()]);
559        }
560        if (str_contains($format, '%O')) {
561            $format = strtr($format, ['%O' => $this->formatGedcomMonth()]);
562        }
563        if (str_contains($format, '%E')) {
564            $format = strtr($format, ['%E' => $this->formatGedcomYear()]);
565        }
566
567        return $format;
568    }
569
570    /**
571     * Generate the %d format for a date.
572     *
573     * @return string
574     */
575    protected function formatDayZeros(): string
576    {
577        if ($this->day > 9) {
578            return I18N::digits($this->day);
579        }
580
581        return I18N::digits('0' . $this->day);
582    }
583
584    /**
585     * Generate the %j format for a date.
586     *
587     * @return string
588     */
589    protected function formatDay(): string
590    {
591        return I18N::digits($this->day);
592    }
593
594    /**
595     * Generate the %l format for a date.
596     *
597     * @return string
598     */
599    protected function formatLongWeekday(): string
600    {
601        return $this->dayNames($this->minimum_julian_day % $this->calendar->daysInWeek());
602    }
603
604    /**
605     * Generate the %D format for a date.
606     *
607     * @return string
608     */
609    protected function formatShortWeekday(): string
610    {
611        return $this->dayNamesAbbreviated($this->minimum_julian_day % $this->calendar->daysInWeek());
612    }
613
614    /**
615     * Generate the %N format for a date.
616     *
617     * @return string
618     */
619    protected function formatIsoWeekday(): string
620    {
621        return I18N::digits($this->minimum_julian_day % 7 + 1);
622    }
623
624    /**
625     * Generate the %w format for a date.
626     *
627     * @return string
628     */
629    protected function formatNumericWeekday(): string
630    {
631        return I18N::digits(($this->minimum_julian_day + 1) % $this->calendar->daysInWeek());
632    }
633
634    /**
635     * Generate the %z format for a date.
636     *
637     * @return string
638     */
639    protected function formatDayOfYear(): string
640    {
641        return I18N::digits($this->minimum_julian_day - $this->calendar->ymdToJd($this->year, 1, 1));
642    }
643
644    /**
645     * Generate the %n format for a date.
646     *
647     * @return string
648     */
649    protected function formatMonth(): string
650    {
651        return I18N::digits($this->month);
652    }
653
654    /**
655     * Generate the %m format for a date.
656     *
657     * @return string
658     */
659    protected function formatMonthZeros(): string
660    {
661        if ($this->month > 9) {
662            return I18N::digits($this->month);
663        }
664
665        return I18N::digits('0' . $this->month);
666    }
667
668    /**
669     * Generate the %F format for a date.
670     *
671     * @param string $case Which grammatical case shall we use
672     *
673     * @return string
674     */
675    protected function formatLongMonth(string $case = 'NOMINATIVE'): string
676    {
677        switch ($case) {
678            case 'GENITIVE':
679                return $this->monthNameGenitiveCase($this->month, $this->isLeapYear());
680            case 'NOMINATIVE':
681                return $this->monthNameNominativeCase($this->month, $this->isLeapYear());
682            case 'LOCATIVE':
683                return $this->monthNameLocativeCase($this->month, $this->isLeapYear());
684            case 'INSTRUMENTAL':
685                return $this->monthNameInstrumentalCase($this->month, $this->isLeapYear());
686            default:
687                throw new InvalidArgumentException($case);
688        }
689    }
690
691    /**
692     * Full month name in genitive case.
693     *
694     * @param int  $month
695     * @param bool $leap_year Some calendars use leap months
696     *
697     * @return string
698     */
699    abstract protected function monthNameGenitiveCase(int $month, bool $leap_year): string;
700
701    /**
702     * Full month name in nominative case.
703     *
704     * @param int  $month
705     * @param bool $leap_year Some calendars use leap months
706     *
707     * @return string
708     */
709    abstract protected function monthNameNominativeCase(int $month, bool $leap_year): string;
710
711    /**
712     * Full month name in locative case.
713     *
714     * @param int  $month
715     * @param bool $leap_year Some calendars use leap months
716     *
717     * @return string
718     */
719    abstract protected function monthNameLocativeCase(int $month, bool $leap_year): string;
720
721    /**
722     * Full month name in instrumental case.
723     *
724     * @param int  $month
725     * @param bool $leap_year Some calendars use leap months
726     *
727     * @return string
728     */
729    abstract protected function monthNameInstrumentalCase(int $month, bool $leap_year): string;
730
731    /**
732     * Abbreviated month name
733     *
734     * @param int  $month
735     * @param bool $leap_year Some calendars use leap months
736     *
737     * @return string
738     */
739    abstract protected function monthNameAbbreviated(int $month, bool $leap_year): string;
740
741    /**
742     * Generate the %M format for a date.
743     *
744     * @return string
745     */
746    protected function formatShortMonth(): string
747    {
748        return $this->monthNameAbbreviated($this->month, $this->isLeapYear());
749    }
750
751    /**
752     * Generate the %y format for a date.
753     * NOTE Short year is NOT a 2-digit year. It is for calendars such as hebrew
754     * which have a 3-digit form of 4-digit years.
755     *
756     * @return string
757     */
758    protected function formatShortYear(): string
759    {
760        return $this->formatLongYear();
761    }
762
763    /**
764     * Generate the %A format for a date.
765     *
766     * @return string
767     */
768    protected function formatGedcomDay(): string
769    {
770        if ($this->day === 0) {
771            return '';
772        }
773
774        return sprintf('%02d', $this->day);
775    }
776
777    /**
778     * Generate the %O format for a date.
779     *
780     * @return string
781     */
782    protected function formatGedcomMonth(): string
783    {
784        // Our simple lookup table doesn't work correctly for Adar on leap years
785        if ($this->month === 7 && $this->calendar instanceof JewishCalendar && !$this->calendar->isLeapYear($this->year)) {
786            return 'ADR';
787        }
788
789        return static::NUMBER_TO_MONTH[$this->month] ?? '';
790    }
791
792    /**
793     * Generate the %E format for a date.
794     *
795     * @return string
796     */
797    protected function formatGedcomYear(): string
798    {
799        if ($this->year === 0) {
800            return '';
801        }
802
803        return sprintf('%04d', $this->year);
804    }
805
806    /**
807     * Generate the %@ format for a calendar escape.
808     *
809     * @return string
810     */
811    protected function formatGedcomCalendarEscape(): string
812    {
813        return static::ESCAPE;
814    }
815
816    /**
817     * Generate the %Y format for a date.
818     *
819     * @return string
820     */
821    protected function formatLongYear(): string
822    {
823        return I18N::digits($this->year);
824    }
825
826    /**
827     * Which months follows this one? Calendars with leap-months should provide their own implementation.
828     *
829     * @return array<int>
830     */
831    protected function nextMonth(): array
832    {
833        return [
834            $this->month === $this->calendar->monthsInYear() ? $this->nextYear($this->year) : $this->year,
835            $this->month % $this->calendar->monthsInYear() + 1,
836        ];
837    }
838
839    /**
840     * Get today’s date in the current calendar.
841     *
842     * @return array<int>
843     */
844    public function todayYmd(): array
845    {
846        return $this->calendar->jdToYmd(Registry::timestampFactory()->now()->julianDay());
847    }
848
849    /**
850     * Convert to today’s date.
851     *
852     * @return AbstractCalendarDate
853     */
854    public function today(): AbstractCalendarDate
855    {
856        $tmp        = clone $this;
857        $ymd        = $tmp->todayYmd();
858        $tmp->year  = $ymd[0];
859        $tmp->month = $ymd[1];
860        $tmp->day   = $ymd[2];
861        $tmp->setJdFromYmd();
862
863        return $tmp;
864    }
865
866    /**
867     * Create a URL that links this date to the WT calendar
868     *
869     * @param string $date_format
870     * @param Tree   $tree
871     *
872     * @return string
873     */
874    public function calendarUrl(string $date_format, Tree $tree): string
875    {
876        if ($this->day !== 0 && strpbrk($date_format, 'dDj')) {
877            // If the format includes a day, and the date also includes a day, then use the day view
878            $view = 'day';
879        } elseif ($this->month !== 0 && strpbrk($date_format, 'FMmn')) {
880            // If the format includes a month, and the date also includes a month, then use the month view
881            $view = 'month';
882        } else {
883            // Use the year view
884            $view = 'year';
885        }
886
887        return route(CalendarPage::class, [
888            'cal'   => $this->calendar->gedcomCalendarEscape(),
889            'year'  => $this->formatGedcomYear(),
890            'month' => $this->formatGedcomMonth(),
891            'day'   => $this->formatGedcomDay(),
892            'view'  => $view,
893            'tree'  => $tree->name(),
894        ]);
895    }
896}
897