0

I have a working symfony project that includes a Holiday class with several grouped static functions around counting non-holiday weekdays. The heart of the code was taken from several answers in this thread.

I use the static methods all around my code-base, including twig extensions, reports, chart creation, etc.

The biggest downside to these is that I periodically have to go into these methods and manually key in Holidays for the coming year (yes, I know I could do several years all at once), but there are occasional "holidays" that I add for things like off-days due to bad weather. Manually adding a holiday and pushing the code to my git repository just feels "wrong", or at least not elegant.

The solution would seem to have holidays into a database and never touch the code again. This means working with doctrine, but this means some dependency injection to get an entity manager to my static methods, and once again this does not feel so elegant.

So what's the solution??

Here's my Holiday class, although the code here works perfectly for my needs. I'm looking for an elegant way to move the $holidays array to a database.

<?php
namespace App\Controller;

use DateInterval;
use DatePeriod;
use DateTime;
use Exception;

class Holiday
{
    private static $workingDays = [1, 2, 3, 4, 5]; # date format = N (1 = Monday, ...)

    /**
     * Is submitted date a holiday?
     *
     * @param DateTime $date
     * @return bool
     */
    public static function isHoliday(DateTime $date): bool
    {
        $holidays = self::getHolidays();

        return
            in_array($date->format('Y-m-d'), $holidays) ||
            in_array($date->format('*-m-d'), $holidays);
    }

    public static function isWorkDay(DateTime $date): bool
    {
        if (!in_array($date->format('N'), self::$workingDays)) {
            return false;
        }
        if (self::isHoliday($date)) {
            return false;
        }

        return true;
    }

    /**
     * Count number of weekdays within a given date span, excluding holidays
     *
     * @param DateTime $from
     * @param DateTime $to
     * @return int
     * @throws Exception
     */
    public static function countWeekDays(DateTime $from, DateTime $to = null)
    {
        if (is_null($to)) {
            return null;
        }

        // from stackoverflow:
        // http://stackoverflow.com/questions/336127/calculate-business-days#19221403

        $from = clone($from);
        $from->setTime(0, 0, 0);
        $to = clone($to);
        $to->setTime(0, 0, 0);
        $interval = new DateInterval('P1D');
        $to->add($interval);
        $period = new DatePeriod($from, $interval, $to);

        $days = 0;
        /** @var DateTime $date */
        foreach ($period as $date) {
            if (self::isWorkDay($date)) {
                $days++;
            }
        }

        return $days;
    }

    /**
     * Return count of weekdays in given month
     *
     * @param int $month
     * @param int $year
     * @return int
     * @throws Exception
     */
    public static function countWeekDaysInMonthToDate(int $month, int $year): int
    {
        $d1 = new DateTime($year . '-' . $month . '-01');
        $d2 = new DateTime($d1->format('Y-m-t'));
        $today = new DateTime();

        return self::countWeekDays($d1, min($d2, $today));
    }


    /**
     * Returns an array of strings representing holidays in format 'Y-m-d'
     *
     * @return array
     */
    private static function getHolidays(): array
    {
        // TODO: Move holidays to database (?)

        $holidays = ['*-12-25', '*-01-01', '*-07-04',
            '2017-04-14', # Good Friday
            '2017-05-29', # Memorial day
            '2017-08-28', # Hurricane Harvey closure
            '2017-08-29', # Hurricane Harvey closure
            '2017-08-30', # Hurricane Harvey closure
            '2017-08-31', # Hurricane Harvey closure
            '2017-09-01', # Hurricane Harvey closure
            '2017-09-04', # Labor day
            '2017-11-23', # Thanksgiving
            '2017-11-24', # Thanksgiving

            #'2018-03-30', # Good Friday
            '2018-05-28', # Memorial day
            '2018-09-03', # Labor day
            '2018-11-22', # Thanksgiving
            '2018-11-23', # Thanksgiving
            '2018-12-24', # Christmas Eve
            '2018-12-31', # New Year's Eve

            '2019-04-19', # Good Friday
            '2019-05-27', # Memorial day
            '2019-09-02', # Labor day
            '2019-11-28', # Thanksgiving
            '2019-11-29', # Thanksgiving
        ]; # variable and fixed holidays

        return $holidays;
    }
}
ehymel
  • 1,360
  • 2
  • 15
  • 24
  • 1
    You will want to use [relative dates](https://www.php.net/manual/en/datetime.formats.relative.php) for your holidays like `fourth Thursday of November` or `fourth Thurdsay of November + 1 day` and modify the `DateTimeImmutale` object with the year to affect. Like: https://3v4l.org/pra1L I created a similar service to do the same but I am bound by an NDA or I would help more. – Will B. May 29 '19 at 02:23
  • Thanks @fyrye, but this does not help for random days like when closed for bad weather, so the flexibility of a database is lost. – ehymel May 29 '19 at 03:29
  • That is to be expected, since organization closures are not technically an observable holiday and not statically definable. But is easily addressed and can be definable without a database, such as reading a [CSV](https://www.php.net/manual/en/function.fgetcsv.php), or [`var_export`](https://www.php.net/manual/en/function.var-export.php) of an array of `$_POST` values in an include file. eg: `$holidays = include '/path/to/file.php';` and file.php contains `return array('....');` – Will B. May 29 '19 at 03:39

1 Answers1

1

As per my suggestion of using relative formats for observable holidays. I added the ability to use callables and date interval periods for more complex dates. I also included easter as a holiday as a use-case, using the easter_date function from PHP's calendar extension.

I also suggest segmenting the dates by year and storing them into a static property, to determine if they have already been loaded, in order to prevent reloading them on multiple iterations.

Example: https://3v4l.org/fnj67

(Demonstrates include file usage)

class Holiday
{            

    /**
     * holidays in [YYYY => [YYYY-MM-DD]] format
     * array|string[][]
     */
    private static $observableHolidays = [];

    //....

    /**
     * Is submitted date a holiday?
     *
     * @param DateTime $date
     * @return bool
     */
    public static function isHoliday(\DateTimeInterface $date): bool
    {
        $holidays = self::getHolidays($date);

        return \array_key_exists($date->format('Y-m-d'), array_flip($holidays[$date->format('Y')]));
    }

    /**
     * @see https://www.php.net/manual/en/function.easter-date.php
     * @param \DateTimeInterface $date
     * @return \DateTimeImmutable
     */
    private static function getEaster(\DateTimeInterface $date): \DateTimeImmutable
    {
        return (new \DateTimeImmutable())->setTimestamp(\easter_date($date->format('Y')));
    }

    /**
     * Returns an array of strings representing holidays in format 'Y-m-d'
     *
     * @return array|string[][]
     */
    public static function getHolidays(\DateTimeInterface $start): array
    {
        //static prevents redeclaring the variable
        static $relativeHolidays = [
            'new years day' => 'first day of January',
            'easter' => [__CLASS__, 'getEaster'],
            'memorial day' => 'second Monday of May',
            'independence day' => 'July 4th',
            'labor day' => 'first Monday of September',
            'thanksgiving' => 'fourth Thursday of November',
            'black friday' => 'fourth Thursday of November + 1 day',
            'christmas' => 'December 25th',
            'new years eve' => 'last day of December',

            //... add others like Veterans Day, MLK, Columbus Day, etc
        ];
        if (!$start instanceof \DateTimeImmutable) {
            //force using DateTimeImmutable
            $start = \DateTimeImmutable::createFromMutable($start);
        }
        //build the holidays to the specified year
        $start = $start->modify('first day of this year');

        //always generate an entire years worth of holidays
        $period = new \DatePeriod($start, new \DateInterval('P1Y'), 0);
        foreach ($period as $date) {
            $year = $date->format('Y');
            if (array_key_exists($year, self::$observableHolidays)) {
                continue;
            }
            self::$observableHolidays[$year] = [];
            foreach (self::$relativeHolidays as $relativeHoliday) {
                 if (\is_callable($relativeHoliday)) {
                    $holidayDate = $relativeHoliday($date);
                } elseif (0 === \strpos($relativeHoliday, 'P')) {
                    $holidayDate = $date->add(new \DateInterval($relativeHoliday));
                } else {
                    $holidayDate = $date->modify($relativeHoliday);
                }
                self::$observableHolidays[$year][] = $holidayDate->format('Y-m-d');
            }
        }

        return self::$observableHolidays;
    }
}

$holidays = Holiday::getHolidays(new \DateTime('2017-08-28'));

Results:

array (
  2017 => 
  array (
    0 => '2017-01-01',
    1 => '2017-04-16',
    2 => '2017-05-08',
    3 => '2017-07-04',
    4 => '2017-09-04',
    5 => '2017-11-23',
    6 => '2017-11-24',
    7 => '2017-12-25',
    8 => '2017-12-31',
  ),
)

Specific closure dates for your organization, that cannot be stored in a relative format, would desirably be stored in a RDBMS like MySQL or SQLite. Which can then be retrieved after the standard observable holidays, when those years are encountered.

However in the absence of a database you can use an include file, much like Symfony does in its container service declarations, which also benefits from using OPcache. Alternatively you can use the Symfony Serializer component to generate, load and save the non-relative data in your desired formatting, (JSON, CSV, XML, YAML).

Here is an example of using an include file dictionary with your current class.

class Holiday
{
    public const CLOSURES_FILE = '/tmp/closures.php';

    //...

    public static function getHolidays(\DateTimeInterface $date): array
    {
        //...

        $holidays = self::$observableHolidays;
        if (\is_file(self::CLOSURES_FILE)) {
           foreach (include self::CLOSURES_FILE as $year => $dates) {
                if (!\array_key_exists($year, $holidays)) {
                    $holidays[$year] = [];
                }
                $holidays[$year] = array_merge($holidays[$year], $dates);
            }
        }

        return $holidays;
    }
}

$date = new \DateTime('2017-08-28');
var_export(Holiday::getHolidays($date));
var_dump(Holiday::isHoliday($date));

Results:

array (
  2017 => 
  array (
    0 => '2017-01-01',
    1 => '2017-04-16',
    2 => '2017-05-08',
    3 => '2017-07-04',
    4 => '2017-09-04',
    5 => '2017-11-23',
    6 => '2017-11-24',
    7 => '2017-12-25',
    8 => '2017-12-31',
    9 => '2017-08-28',
    10 => '2017-08-29',
    11 => '2017-08-30',
    12 => '2017-08-31',
    13 => '2017-09-01',
  ),
)
bool(true)

To save the include file dictionary, you can load and save these values in your application how you like, such as with a simple form $_POST values.

$dictionary_array = ['year' => ['date1', 'date2']];
file_put_contents(Holiday::CLOSURES_FILE, '<?php return ' . var_export($dictionary_array) . ';');

As your application is version controlled, use or set the desired dictionary file path to one that is ignored in your .gitignore file, such as var/holidays/holiday_dates.php.


In my Symfony project, I add the known relative and non-relative date values as parameters from the configs to my Holiday service, such as 2001-09-11. I then use a query service to inject the unknown non-relative dates from the database into the Holiday class, such as your hurricane closures. My Holiday service is used throughout the application only using DI, including inside of Twig functions, as opposed to using static method calls, but this could be done by using a CompilerPass, on a static method to set the dates.

Since you are using static methods to directly access the results, it would require a major application refactor to any service that uses a Holiday static method in favor of using it as a Symfony service.

Will B.
  • 17,883
  • 4
  • 67
  • 69
  • 1
    Thanks for the in depth answer. I've taken your suggestion of a `CLOSURES_FILE` and this fits my needs very well with minimal modification of my code base. – ehymel May 29 '19 at 15:46