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