-
diff --git a/agent/modals/calendar/calendar_share.php b/agent/modals/calendar/calendar_share.php
new file mode 100644
index 00000000..a2aa25ab
--- /dev/null
+++ b/agent/modals/calendar/calendar_share.php
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
+ $config_base_url is not set in config.php.
+ Without it there is no hostname to build a subscription link from. Set it
+ to the hostname this install is reached on and reopen this dialog.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+$calendar_name published - copy the subscription link from the share dialog");
+
+ } else {
+
+ mysqli_query($mysqli, "UPDATE calendars SET
+ calendar_feed_busy_only = $busy_only
+ WHERE calendar_id = $calendar_id");
+
+ logAudit("Calendar", "Edit", "$session_name updated feed settings for calendar $calendar_name", 0, $calendar_id);
+
+ flashAlert("Feed settings for
$calendar_name updated");
+ }
+
+ redirect();
+
+}
+
+if (isset($_GET['regenerate_calendar_feed'])) {
+
+ validateCSRFToken();
+
+ enforceAdminPermission();
+
+ $calendar_id = intval($_GET['regenerate_calendar_feed']);
+
+ $calendar_name = escapeSql(getFieldById('calendars', $calendar_id, 'calendar_name'));
+
+ $feed_key = escapeSql(randomString(32));
+
+ mysqli_query($mysqli, "UPDATE calendars SET
+ calendar_feed_key = '$feed_key',
+ calendar_feed_created_at = NOW(),
+ calendar_feed_accessed_at = NULL
+ WHERE calendar_id = $calendar_id");
+
+ logAudit("Calendar", "Share", "$session_name regenerated the feed link for calendar $calendar_name", 0, $calendar_id);
+
+ flashAlert("Feed link for
$calendar_name regenerated - existing subscribers will stop updating and must re-subscribe", 'error');
+
+ redirect();
+
+}
+
+if (isset($_GET['unshare_calendar'])) {
+
+ validateCSRFToken();
+
+ enforceAdminPermission();
+
+ $calendar_id = intval($_GET['unshare_calendar']);
+
+ $calendar_name = escapeSql(getFieldById('calendars', $calendar_id, 'calendar_name'));
+
+ mysqli_query($mysqli, "UPDATE calendars SET
+ calendar_feed_key = NULL,
+ calendar_feed_busy_only = 0,
+ calendar_feed_created_at = NULL,
+ calendar_feed_accessed_at = NULL
+ WHERE calendar_id = $calendar_id");
+
+ logAudit("Calendar", "Share", "$session_name stopped sharing calendar $calendar_name", 0, $calendar_id);
+
+ flashAlert("Calendar
$calendar_name is no longer shared", 'error');
+
+ redirect();
+
+}
+
if (isset($_POST['add_event'])) {
validateCSRFToken();
@@ -79,7 +176,7 @@ if (isset($_POST['add_event'])) {
enforceClientAccess();
}
- mysqli_query($mysqli,"INSERT INTO calendar_events SET event_title = '$title', event_location = '$location', event_description = '$description', event_start = '$start', event_end = '$end', event_repeat = '$repeat', event_calendar_id = $calendar_id, event_client_id = $client_id");
+ mysqli_query($mysqli,"INSERT INTO calendar_events SET event_title = '$title', event_location = '$location', event_description = '$description', event_start = '$start', event_end = '$end', event_all_day = $all_day, event_repeat = '$repeat', event_calendar_id = $calendar_id, event_client_id = $client_id");
$event_id = mysqli_insert_id($mysqli);
@@ -158,7 +255,7 @@ if (isset($_POST['edit_event'])) {
$event_id = intval($_POST['event_id']);
- mysqli_query($mysqli,"UPDATE calendar_events SET event_title = '$title', event_location = '$location', event_description = '$description', event_start = '$start', event_end = '$end', event_repeat = '$repeat', event_calendar_id = $calendar_id, event_client_id = $client_id WHERE event_id = $event_id");
+ mysqli_query($mysqli,"UPDATE calendar_events SET event_title = '$title', event_location = '$location', event_description = '$description', event_start = '$start', event_end = '$end', event_all_day = $all_day, event_repeat = '$repeat', event_calendar_id = $calendar_id, event_client_id = $client_id WHERE event_id = $event_id");
//If email is checked
if ($email_event == 1) {
diff --git a/agent/post/event_model.php b/agent/post/event_model.php
index 180cc7bb..092390e0 100644
--- a/agent/post/event_model.php
+++ b/agent/post/event_model.php
@@ -5,8 +5,37 @@ $calendar_id = intval($_POST['calendar']);
$title = escapeSql($_POST['title']);
$location = escapeSql($_POST['location']);
$description = escapeSql($_POST['description']);
-$start = escapeSql($_POST['start']);
-$end = escapeSql($_POST['end']);
+$all_day = isset($_POST['all_day']) ? 1 : 0;
+
+/*
+ * The form posts the date and the time as separate fields, so recombine them into
+ * the DATETIME columns.
+ *
+ * All-day events are pinned to midnight at both ends, and event_end holds the LAST
+ * DAY the event covers - the same thing the form asks for. Both FullCalendar and
+ * iCalendar treat an all-day end as exclusive, so the render and feed paths add a
+ * day; do not add one here as well.
+ */
+$start_date = $_POST['start_date'] ?? '';
+$end_date = !empty($_POST['end_date']) ? $_POST['end_date'] : $start_date;
+
+if ($all_day) {
+ $start_raw = "$start_date 00:00:00";
+ $end_raw = "$end_date 00:00:00";
+} else {
+ $start_time = !empty($_POST['start_time']) ? $_POST['start_time'] : '00:00';
+ $end_time = !empty($_POST['end_time']) ? $_POST['end_time'] : $start_time;
+ $start_raw = "$start_date $start_time";
+ $end_raw = "$end_date $end_time";
+}
+
+// A malformed value would otherwise land in 1970 via strtotime() returning false
+$start_ts = strtotime($start_raw) ?: time();
+$end_ts = strtotime($end_raw) ?: $start_ts;
+
+$start = escapeSql(date('Y-m-d H:i:s', $start_ts));
+$end = escapeSql(date('Y-m-d H:i:s', $end_ts));
+
$repeat = escapeSql($_POST['repeat'] ?? 0);
$client_id = intval($_POST['client_id']);
$email_event = intval($_POST['email_event'] ?? 0);
diff --git a/db.sql b/db.sql
index 95b44ea6..6997be88 100644
--- a/db.sql
+++ b/db.sql
@@ -408,6 +408,7 @@ CREATE TABLE `calendar_events` (
`event_description` longtext DEFAULT NULL,
`event_start` datetime NOT NULL,
`event_end` datetime DEFAULT NULL,
+ `event_all_day` tinyint(1) NOT NULL DEFAULT 0,
`event_repeat` varchar(200) DEFAULT NULL,
`event_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`event_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(),
@@ -432,10 +433,15 @@ CREATE TABLE `calendars` (
`calendar_id` int(11) NOT NULL AUTO_INCREMENT,
`calendar_name` varchar(200) NOT NULL,
`calendar_color` varchar(200) NOT NULL,
+ `calendar_feed_key` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL,
+ `calendar_feed_busy_only` tinyint(1) NOT NULL DEFAULT 0,
+ `calendar_feed_created_at` datetime DEFAULT NULL,
+ `calendar_feed_accessed_at` datetime DEFAULT NULL,
`calendar_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`calendar_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(),
`calendar_archived_at` datetime DEFAULT NULL,
- PRIMARY KEY (`calendar_id`)
+ PRIMARY KEY (`calendar_id`),
+ UNIQUE KEY `calendar_feed_key` (`calendar_feed_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
diff --git a/functions.php b/functions.php
index 4730cb4c..e98cb6e4 100644
--- a/functions.php
+++ b/functions.php
@@ -24,3 +24,4 @@ require_once __DIR__ . '/functions/db.php';
require_once __DIR__ . '/functions/payments.php';
require_once __DIR__ . '/functions/sla.php';
require_once __DIR__ . '/functions/export.php';
+require_once __DIR__ . '/functions/calendar.php';
diff --git a/functions/calendar.php b/functions/calendar.php
new file mode 100644
index 00000000..b3dfb0ca
--- /dev/null
+++ b/functions/calendar.php
@@ -0,0 +1,371 @@
+ 75) {
+ $folded .= $buffer . "\r\n";
+ $buffer = ' ';
+ }
+ $buffer .= $char;
+ }
+
+ return $folded . $buffer;
+}
+
+/*
+ * Converts an ITFlow datetime to an iCalendar UTC DATE-TIME
+ * Stored datetimes are in the instance's configured timezone. Emitting UTC for
+ * everything means the feed needs no VTIMEZONE block at all, which removes the
+ * largest source of breakage in hand-written ICS.
+ */
+function icsFormatUtc($datetime, $timezone) {
+
+ if (empty($datetime) || $datetime === '0000-00-00 00:00:00') {
+ return null;
+ }
+
+ try {
+ $dt = new DateTime($datetime, new DateTimeZone($timezone));
+ $dt->setTimezone(new DateTimeZone('UTC'));
+ } catch (Exception $e) {
+ return null;
+ }
+
+ return $dt->format('Ymd\THis\Z');
+}
+
+/*
+ * Converts an ITFlow datetime to an iCalendar DATE value (all-day events)
+ * No timezone conversion - a floating date must stay on the day it was entered
+ */
+function icsFormatDate($datetime, $offset_days = 0) {
+
+ if (empty($datetime) || $datetime === '0000-00-00 00:00:00') {
+ return null;
+ }
+
+ $timestamp = strtotime($datetime);
+
+ if ($timestamp === false) {
+ return null;
+ }
+
+ return date('Ymd', strtotime("$offset_days day", $timestamp));
+}
+
+/*
+ * True when an event looks like an all-day event
+ * Fallback for rows written before event_all_day existed (added in 2.5.9): a
+ * midnight start, and either no end or a midnight end - the same rule the
+ * calendar page itself rendered by, and the rule the 2.5.9 backfill applies.
+ */
+function icsEventIsAllDay($start, $end) {
+
+ if (empty($start) || date('H:i:s', strtotime($start)) !== '00:00:00') {
+ return false;
+ }
+
+ if (empty($end) || $end === '0000-00-00 00:00:00') {
+ return true;
+ }
+
+ return date('H:i:s', strtotime($end)) === '00:00:00';
+}
+
+/*
+ * Maps ITFlow's event_repeat wording onto an RRULE
+ * The repeat select is disabled in both event modals as of 2.5.x, so this only
+ * applies to rows created before it was disabled or after it is re-enabled
+ */
+function icsRepeatToRrule($repeat) {
+
+ $map = [
+ 'Day' => 'FREQ=DAILY',
+ 'Week' => 'FREQ=WEEKLY',
+ 'Month' => 'FREQ=MONTHLY',
+ 'Year' => 'FREQ=YEARLY'
+ ];
+
+ return $map[(string) $repeat] ?? null;
+}
+
+/*
+ * Builds a complete VCALENDAR document for one calendar
+ *
+ * $calendar - the calendars row (name, color, feed settings)
+ * $events - array of calendar_events rows
+ * $timezone - config_timezone, used to shift stored datetimes to UTC
+ * $host - config_base_url, used to build stable globally-unique UIDs
+ *
+ * Returns the document with CRLF line endings, ready to send.
+ */
+function buildCalendarFeedIcs(array $calendar, array $events, $timezone, $host) {
+
+ $busy_only = !empty($calendar['calendar_feed_busy_only']);
+ $calendar_name = (string) $calendar['calendar_name'];
+ $host = $host ?: 'itflow.local';
+ $now_utc = gmdate('Ymd\THis\Z');
+
+ // X-WR-CALNAME is non-standard and clients render its value literally, so a
+ // TEXT-escaped comma shows up as a visible backslash in the Google Calendar
+ // sidebar. Strip only what would break the line structure.
+ $calendar_name_header = str_replace(["\r\n", "\r", "\n"], ' ', $calendar_name);
+
+ $lines = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//ITFlow//ITFlow Calendar Feed//EN',
+ 'CALSCALE:GREGORIAN',
+ 'METHOD:PUBLISH',
+ 'X-WR-CALNAME:' . $calendar_name_header,
+ 'X-WR-TIMEZONE:' . $timezone,
+ // Nextcloud reads REFRESH-INTERVAL / X-PUBLISHED-TTL and stores it against
+ // the subscription - but only during a refresh run, and only if no rate is
+ // stored yet. The first scheduled run is still gated by its own default
+ // (P1D), so there is a cold start before this takes effect. Google ignores
+ // it entirely.
+ 'REFRESH-INTERVAL;VALUE=DURATION:PT15M',
+ 'X-PUBLISHED-TTL:PT15M'
+ ];
+
+ if (!empty($calendar['calendar_color'])) {
+ $lines[] = 'X-APPLE-CALENDAR-COLOR:' . $calendar['calendar_color'];
+ }
+
+ foreach ($events as $event) {
+
+ $event_id = intval($event['event_id']);
+ $start = $event['event_start'];
+ $end = $event['event_end'];
+
+ $lines[] = 'BEGIN:VEVENT';
+
+ // Stable across edits and across feed regenerations, so subscribers
+ // update events in place instead of duplicating them
+ $lines[] = "UID:itflow-event-$event_id@$host";
+
+ // Derived from the event rather than the request clock: a feed whose bytes
+ // change on every fetch can never produce an ETag hit, which would defeat
+ // the conditional-GET path in guest_calendar_feed.php
+ $stamp = icsFormatUtc($event['event_updated_at'] ?: $event['event_created_at'], $timezone);
+ $lines[] = 'DTSTAMP:' . ($stamp ?: $now_utc);
+
+ // event_all_day is authoritative as of database version 2.5.9; the
+ // heuristic remains as a fallback for a row that predates the backfill
+ $all_day = isset($event['event_all_day'])
+ ? !empty($event['event_all_day'])
+ : icsEventIsAllDay($start, $end);
+
+ if ($all_day) {
+
+ $dtstart = icsFormatDate($start);
+
+ // event_end holds the last day the event covers, which is what the event
+ // modal asks for. DTEND is exclusive for DATE values, so it has to land
+ // one day past that. A missing or non-advancing end still falls back to a
+ // single day rather than rendering as zero-length.
+ $dtend = icsFormatDate($end, 1);
+
+ if (empty($dtend) || $dtend <= $dtstart) {
+ $dtend = icsFormatDate($start, 1);
+ }
+
+ $lines[] = "DTSTART;VALUE=DATE:$dtstart";
+ $lines[] = "DTEND;VALUE=DATE:$dtend";
+
+ } else {
+
+ $dtstart = icsFormatUtc($start, $timezone);
+
+ // The event modals default a blank end to start + 1 hour client-side;
+ // match that rather than emitting an instantaneous event
+ $dtend = icsFormatUtc($end, $timezone);
+ if (empty($dtend) || $dtend <= $dtstart) {
+ $dtend = icsFormatUtc(date('Y-m-d H:i:s', strtotime("$start +1 hour")), $timezone);
+ }
+
+ $lines[] = "DTSTART:$dtstart";
+ $lines[] = "DTEND:$dtend";
+ }
+
+ $rrule = icsRepeatToRrule($event['event_repeat'] ?? '');
+ if ($rrule) {
+ $lines[] = "RRULE:$rrule";
+ }
+
+ if ($busy_only) {
+
+ // Titles, descriptions and locations all withheld - the feed shows
+ // only that the time is occupied
+ $lines[] = 'SUMMARY:Busy';
+ $lines[] = 'TRANSP:OPAQUE';
+
+ } else {
+
+ $lines[] = 'SUMMARY:' . icsEscapeText($event['event_title']);
+
+ if (!empty($event['event_location'])) {
+ $lines[] = 'LOCATION:' . icsEscapeText($event['event_location']);
+ }
+
+ if (!empty($event['event_description'])) {
+ $lines[] = 'DESCRIPTION:' . icsEscapeText($event['event_description']);
+ }
+ }
+
+ if (!empty($event['event_created_at'])) {
+ $created = icsFormatUtc($event['event_created_at'], $timezone);
+ if ($created) {
+ $lines[] = "CREATED:$created";
+ }
+ }
+
+ // Lets well-behaved clients spot changed events without a SEQUENCE counter,
+ // which calendar_events has nowhere to store
+ if ($stamp) {
+ $lines[] = "LAST-MODIFIED:$stamp";
+ }
+
+ $lines[] = 'END:VEVENT';
+ }
+
+ $lines[] = 'END:VCALENDAR';
+
+ $output = '';
+ foreach ($lines as $line) {
+ $output .= icsFoldLine($line) . "\r\n";
+ }
+
+ return $output;
+}
+
+/*
+ * Expands a repeating event into concrete occurrences within a window
+ *
+ * ITFlow stores recurrence as a single row with event_repeat set to Day, Week,
+ * Month or Year - there is no interval, count or until, and no per-occurrence
+ * override. The calendar page needs real instances to render, because the bundled
+ * FullCalendar build has no rrule plugin (adding one would mean vendoring rrule.js
+ * as well). The ICS feed does not use this: it emits an RRULE and lets the
+ * subscribing client do its own expansion.
+ *
+ * Offsets are computed from the original start rather than by stepping a running
+ * date, so a long series cannot drift.
+ *
+ * Returns an array of ['start' => ..., 'end' => ...] datetime strings, always
+ * including the original occurrence.
+ */
+function expandRecurringEvent(array $event, $window_start, $window_end, $limit = 750) {
+
+ $start = $event['event_start'];
+ $repeat = (string) ($event['event_repeat'] ?? '');
+
+ if (empty($start) || !icsRepeatToRrule($repeat)) {
+ return [['start' => $start, 'end' => $event['event_end']]];
+ }
+
+ try {
+ $base = new DateTime($start);
+ $from = new DateTime($window_start);
+ $to = new DateTime($window_end);
+ } catch (Exception $e) {
+ return [['start' => $start, 'end' => $event['event_end']]];
+ }
+
+ // Preserve the original duration on every occurrence
+ $duration = 0;
+ if (!empty($event['event_end']) && $event['event_end'] !== '0000-00-00 00:00:00') {
+ $duration = max(0, strtotime($event['event_end']) - strtotime($start));
+ }
+
+ $step = [
+ 'Day' => 'day',
+ 'Week' => 'week',
+ 'Month' => 'month',
+ 'Year' => 'year'
+ ][$repeat];
+
+ $base_day = (int) $base->format('j');
+ $occurrences = [];
+ $i = 0;
+
+ // Walk forward from the original start; a series that begins after the window
+ // simply yields nothing until it reaches it
+ while (count($occurrences) < $limit) {
+
+ $candidate = clone $base;
+
+ if ($i > 0) {
+ $candidate->modify("+$i $step");
+
+ // Monthly on the 31st, or yearly on Feb 29: PHP rolls the overflow into
+ // the next month, which is not what a recurrence means. RFC 5545 skips
+ // those occurrences, so skip them here too rather than silently moving
+ // the event to the 1st or the 3rd.
+ if (($step === 'month' || $step === 'year') && (int) $candidate->format('j') !== $base_day) {
+ $i++;
+ if ($candidate > $to) {
+ break;
+ }
+ continue;
+ }
+ }
+
+ if ($candidate > $to) {
+ break;
+ }
+
+ if ($candidate >= $from) {
+ $occurrence_end = null;
+ if ($duration > 0) {
+ $end_dt = clone $candidate;
+ $end_dt->modify("+$duration second");
+ $occurrence_end = $end_dt->format('Y-m-d H:i:s');
+ }
+ $occurrences[] = [
+ 'start' => $candidate->format('Y-m-d H:i:s'),
+ 'end' => $occurrence_end
+ ];
+ }
+
+ $i++;
+ }
+
+ return $occurrences;
+}
diff --git a/guest/guest_calendar_feed.php b/guest/guest_calendar_feed.php
new file mode 100644
index 00000000..722470a8
--- /dev/null
+++ b/guest/guest_calendar_feed.php
@@ -0,0 +1,114 @@
+ 64) {
+ feedNotFound();
+}
+
+$feed_key = escapeSql($_GET['key']);
+
+$sql = mysqli_query(
+ $mysqli,
+ "SELECT * FROM calendars
+ WHERE calendar_feed_key = '$feed_key'
+ AND calendar_feed_key IS NOT NULL
+ AND calendar_archived_at IS NULL
+ LIMIT 1"
+);
+
+if (mysqli_num_rows($sql) !== 1) {
+ feedNotFound();
+}
+
+$calendar = mysqli_fetch_assoc($sql);
+$calendar_id = intval($calendar['calendar_id']);
+
+// Gather events for this calendar only
+$window_start = date('Y-m-d H:i:s', strtotime('-' . FEED_MONTHS_PAST . ' months'));
+$window_end = date('Y-m-d H:i:s', strtotime('+' . FEED_MONTHS_FUTURE . ' months'));
+
+$events_sql = mysqli_query(
+ $mysqli,
+ "SELECT * FROM calendar_events
+ WHERE event_calendar_id = $calendar_id
+ AND event_archived_at IS NULL
+ AND (
+ (event_repeat IS NOT NULL AND event_repeat != '')
+ OR event_start BETWEEN '$window_start' AND '$window_end'
+ )
+ ORDER BY event_start ASC"
+);
+
+$events = [];
+while ($row = mysqli_fetch_assoc($events_sql)) {
+ $events[] = $row;
+}
+
+$ics = buildCalendarFeedIcs($calendar, $events, $config_timezone, $config_base_url);
+
+// Let clients that send If-None-Match skip the transfer entirely
+$etag = '"' . md5($ics) . '"';
+
+if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && trim($_SERVER['HTTP_IF_NONE_MATCH']) === $etag) {
+ header("HTTP/1.1 304 Not Modified");
+ header("ETag: $etag");
+ exit();
+}
+
+// Record that something is actually subscribed, without turning a hammered URL
+// into one write per request
+mysqli_query(
+ $mysqli,
+ "UPDATE calendars
+ SET calendar_feed_accessed_at = NOW()
+ WHERE calendar_id = $calendar_id
+ AND (calendar_feed_accessed_at IS NULL OR calendar_feed_accessed_at < DATE_SUB(NOW(), INTERVAL 5 MINUTE))"
+);
+
+$filename = preg_replace('/[^A-Za-z0-9_-]/', '_', $calendar['calendar_name']) . '.ics';
+
+header("Content-Type: text/calendar; charset=utf-8");
+header("Content-Disposition: inline; filename=\"$filename\"");
+// No explicit Content-Length - mod_deflate or nginx gzip would compress the body
+// after this point and leave the declared length wrong, which some clients treat
+// as a truncated response
+header("Cache-Control: private, max-age=300");
+header("ETag: $etag");
+
+echo $ics;
diff --git a/js/app.js b/js/app.js
index 36d15e1a..be9b2a4b 100644
--- a/js/app.js
+++ b/js/app.js
@@ -410,3 +410,95 @@ $(document).ready(function() {
// Data Tables
new DataTable('.dataTables');
});
+
+/*
+ * Calendar event modals - the All day switch shows or hides the time row.
+ *
+ * The form uses four separate fields (date from / date to / time from / time to),
+ * so nothing here rewrites a value or changes an input type. An earlier version
+ * flipped a single datetime-local input to type="date", which a browser answers by
+ * silently discarding a value it now considers invalid - a fragile arrangement that
+ * left the fields empty whenever this handler had not run.
+ *
+ * required tracks visibility: a hidden required field blocks form submission with an
+ * unfocusable-element error that the user cannot act on.
+ *
+ * Delegated on document because the edit event modal is injected by ajax. The
+ * namespaced .off() keeps this to a single handler - modal_footer.php re-loads this
+ * file on every ajax modal open.
+ */
+$(document).off('change.itflowAllDay').on('change.itflowAllDay', '.event-all-day-toggle', function () {
+
+ const allDay = this.checked;
+ const timeFields = document.getElementById(this.id.replace(/_all_day$/, '_time_fields'));
+
+ if (!timeFields) {
+ return;
+ }
+
+ timeFields.classList.toggle('d-none', allDay);
+
+ timeFields.querySelectorAll('input').forEach(function (field) {
+ if (allDay) {
+ field.removeAttribute('required');
+ } else {
+ field.setAttribute('required', 'required');
+ }
+ });
+});
+
+/*
+ * Keep the end date at or after the start date, without shortening a longer span
+ * the user has already chosen.
+ */
+$(document).off('change.itflowEventDate').on('change.itflowEventDate', '.event-start-date', function () {
+
+ const endField = document.getElementById(this.id.replace(/_start_date$/, '_end_date'));
+
+ if (!endField || !this.value) {
+ return;
+ }
+
+ if (!endField.value || endField.value < this.value) {
+ endField.value = this.value;
+ }
+});
+
+/*
+ * Default the end time to an hour after the start, leaving a longer span alone.
+ * A start late in the evening rolls the end onto the following day rather than
+ * wrapping round to an end that precedes the start.
+ */
+$(document).off('change.itflowEventTime').on('change.itflowEventTime', '.event-start-time', function () {
+
+ const prefix = this.id.replace(/_start_time$/, '');
+ const endField = document.getElementById(prefix + '_end_time');
+
+ if (!endField || !this.value) {
+ return;
+ }
+
+ if (endField.value && endField.value > this.value) {
+ return;
+ }
+
+ const parts = this.value.split(':');
+ const end = new Date(2000, 0, 1, Number(parts[0]), Number(parts[1]));
+ end.setHours(end.getHours() + 1);
+
+ const pad = (n) => String(n).padStart(2, '0');
+ endField.value = pad(end.getHours()) + ':' + pad(end.getMinutes());
+
+ // Crossed midnight - carry the end date forward so the event still ends after
+ // it starts
+ if (end.getDate() !== 1) {
+ const startDate = document.getElementById(prefix + '_start_date');
+ const endDate = document.getElementById(prefix + '_end_date');
+
+ if (startDate && endDate && startDate.value && endDate.value <= startDate.value) {
+ const next = new Date(startDate.value + 'T00:00:00');
+ next.setDate(next.getDate() + 1);
+ endDate.value = next.getFullYear() + '-' + pad(next.getMonth() + 1) + '-' + pad(next.getDate());
+ }
+ }
+});
diff --git a/js/confirm_modal.js b/js/confirm_modal.js
index 3055ef04..682a5211 100644
--- a/js/confirm_modal.js
+++ b/js/confirm_modal.js
@@ -1,5 +1,7 @@
+// Delegated on document rather than bound to the links present at page load, so
+// that confirm-link also works on markup injected by an ajax modal
$(document).ready(function() {
- $("a.confirm-link").click(function(e) {
+ $(document).off('click.itflowConfirm').on('click.itflowConfirm', 'a.confirm-link', function(e) {
e.preventDefault();
// Save the link reference to use after confirmation