A very common need related to generating a calendar is the scheduling of events. Events can be in the form of one-off events, which take place on one day, or on a weekend. There is a much greater need, however, to track events that are recurring. We need to account for the start date, the recurring interval (daily, weekly, monthly), and the number of occurrences or a specific end date.
DateTime extension admirably suited to event generation:namespace Application\I18n;
use DateTime;
use DatePeriod;
use DateInterval;
use InvalidArgumentException;
class Event
{
// code
}public in order to economize on the number of getters and setters needed. The intervals are defined as sprintf() format strings; %d will be substituted for a value:const INTERVAL_DAY = 'P%dD'; const INTERVAL_WEEK = 'P%dW'; const INTERVAL_MONTH = 'P%dM'; const FLAG_FIRST = 'FIRST'; // 1st of the month const ERROR_INVALID_END = 'Need to supply either # occurrences or an end date'; const ERROR_INVALID_DATE = 'String i.e. YYYY-mm-dd or DateTime instance only'; const ERROR_INVALID_INTERVAL = 'Interval must take the form "P\d+(D | W | M)"'; public $id; public $flag; public $value; public $title; public $locale; public $interval; public $description; public $occurrences; public $nextDate; protected $endDate; protected $startDate;
public function __construct($title,
$description,
$startDate,
$interval,
$value,
$occurrences = NULL,
$endDate = NULL,
$flag = NULL)
{events table. Here we use md5() not for security purposes, but rather to quickly generate a hash so that IDs have a consistent appearance:$this->id = md5($title . $interval . $value) . sprintf('%04d', rand(0,9999));
$this->flag = $flag;
$this->value = $value;
$this->title = $title;
$this->description = $description;
$this->occurrences = $occurrences;sprintf() pattern used to construct a proper DateInterval instance:try {
$this->interval = new DateInterval(sprintf($interval, $value));
} catch (Exception $e) {
error_log($e->getMessage());
throw new InvalidArgumentException(self::ERROR_INVALID_INTERVAL);
}$startDate, we call stringOrDate(). We then attempt to generate a value for $endDate by calling either stringOrDate() or calcEndDateFromOccurrences(). If we have neither an end date nor a number of occurrences, an exception is thrown: $this->startDate = $this->stringOrDate($startDate);
if ($endDate) {
$this->endDate = $this->stringOrDate($endDate);
} elseif ($occurrences) {
$this->endDate = $this->calcEndDateFromOccurrences();
} else {
throw new InvalidArgumentException(self::ERROR_INVALID_END);
}
$this->nextDate = $this->startDate;
}stringOrDate() method consists of a few lines of code that check the data type of the date variable, and return a DateTime instance or NULL:protected function stringOrDate($date)
{
if ($date === NULL) {
$newDate = NULL;
} elseif ($date instanceof DateTime) {
$newDate = $date;
} elseif (is_string($date)) {
$newDate = new DateTime($date);
} else {
throw new InvalidArgumentException(self::ERROR_INVALID_END);
}
return $newDate;
}calcEndDateFromOccurrences() method from the constructor if $occurrences is set so that we'll know the end date for this event. We take advantage of the DatePeriod class, which provides an iteration based on a start date, DateInterval, and number of occurrences:protected function calcEndDateFromOccurrences()
{
$endDate = new DateTime('now');
$period = new DatePeriod(
$this->startDate, $this->interval, $this->occurrences);
foreach ($period as $date) {
$endDate = $date;
}
return $endDate;
}__toString() magic method, which simple echoes the title of the event:public function __toString()
{
return $this->title;
}Event class is getNextDate(), which is used when generating a calendar:public function getNextDate(DateTime $today)
{
if ($today > $this->endDate) {
return FALSE;
}
$next = clone $today;
$next->add($this->interval);
return $next;
}Application\I18n\Calendar class described in the previous recipe. With a bit of minor surgery, we are ready to tie our newly defined Event class into the calendar. First we add a new property, $events, and a method to add events in the form of an array. We use the Event::$id property to make sure events are merged and not overwritten:protected $events = array();
public function addEvent(Event $event)
{
$this->events[$event->id] = $event;
}processEvents(), which adds an Event instance to a Day object when building the year calendar. First we check to see whether there are any events, and whether or not the Day object is NULL. As you may recall, it's likely that the first day of the month doesn't fall on the first day of the week, and thus the need to set the value of a Day object to NULL. We certainly do not want to add events to a non-operative day! We then call Event::getNextDate() and see whether the dates match. If so, we store the Event into Day::$events[] and set the next date on the Event object:protected function processEvents($dayObj, $cal)
{
if ($this->events && $dayObj()) {
$calDateTime = $cal->toDateTime();
foreach ($this->events as $id => $eventObj) {
$next = $eventObj->getNextDate($eventObj->nextDate);
if ($next) {
if ($calDateTime->format('Y-m-d') ==
$eventObj->nextDate->format('Y-m-d')) {
$dayObj->events[$eventObj->id] = $eventObj;
$eventObj->nextDate = $next;
}
}
}
}
return $dayObj;
}Note that we do not do a direct comparison of the two objects. Two reasons for this: first of all, one is a DateTime instance, the other is an IntlCalendar instance. The other, more compelling reason, is that it's possible that hours:minutes:seconds were included when the DateTime instance was obtained, resulting in actual value differences between the two objects.
processEvents() in the buildMonthArray() method so that it looks like this: while ($day <= $maxDaysInMonth) {
for ($dow = 1; $dow <= 7; $dow++) {
// add this to the existing code:
$dayObj = $this->processEvents(new Day($value), $cal);
$monthArray[$weekOfYear][$dow] = $dayObj;
}
}getWeekDaysRow(), adding the necessary code to output event information inside the box along with the date:protected function getWeekDaysRow($type, $week)
{
$output = '<tr style="height:' . $this->height . ';">';
$width = (int) (100/7);
foreach ($week as $day) {
$events = '';
if ($day->events) {
foreach ($day->events as $single) {
$events .= '<br>' . $single->title;
if ($type == self::DAY_FULL) {
$events .= '<br><i>' . $single->description . '</i>';
}
}
}
$output .= '<td style="vertical-align:top;" width="' . $width . '%">'
. $day() . $events . '</td>';
}
$output .= '</tr>' . PHP_EOL;
return $output;
}To tie events to the calendar, first code the Application\I18n\Event class described in steps 1 to 10. Next, modify Application\I18n\Calendar as described in steps 11 to 14. You can then create a test script, chap_08_recurring_events.php, which sets up autoloading and creates Locale and Calendar instances. For the purposes of illustration, go ahead and use 'es_ES' as a locale:
<?php
require __DIR__ . '/../Application/Autoload/Loader.php';
Application\Autoload\Loader::init(__DIR__ . '/..');
use Application\I18n\ { Locale, Calendar, Event };
try {
$year = 2016;
$localeEs = new Locale('es_ES');
$calendarEs = new Calendar($localeEs);Now we can start defining and adding events to the calendar. The first example adds an event that lasts 3 days and starts on 8 January 2016:
// add event: 3 days
$title = 'Conf';
$description = 'Special 3 day symposium on eco-waste';
$startDate = '2016-01-08';
$event = new Event($title, $description, $startDate,
Event::INTERVAL_DAY, 1, 2);
$calendarEs->addEvent($event);Here is another example, an event that occurs on the first of every month until September 2017:
$title = 'Pay Rent';
$description = 'Sent rent check to landlord';
$startDate = new DateTime('2016-02-01');
$event = new Event($title, $description, $startDate,
Event::INTERVAL_MONTH, 1, '2017-09-01', NULL, Event::FLAG_FIRST);
$calendarEs->addEvent($event);You can then add sample weekly, bi-weekly, monthly, and so on events as desired. You can then close the try...catch block, and produce suitable display logic:
} catch (Throwable $e) {
$message = $e->getMessage();
}
?>
<!DOCTYPE html>
<head>
<title>PHP 7 Cookbook</title>
<meta http-equiv="content-type" content="text/html;charset=utf-8" />
<link rel="stylesheet" type="text/css" href="php7cookbook_html_table.css">
</head>
<body>
<h3>Year: <?= $year ?></h3>
<?= $calendarEs->calendarForYear($year, 'Europe/Berlin',
Calendar::DAY_3, Calendar::MONTH_FULL, 2); ?>
<?= $calendarEs->calendarForMonth($year, 1 , 'Europe/Berlin',
Calendar::DAY_FULL); ?>
</body>
</html>Here is the output showing the first few months of the year:

IntlCalendar field constants that can be used with get(), please refer to this page: http://php.net/manual/en/class.intlcalendar.php#intlcalendar.constants