Ticket Calendar Sync Developer Guide - edmozley/freeitsm GitHub Wiki
How ticket schedules reach an analyst's real calendar and come back again: the provider contract, why the push reconciles rather than reacts, the three guards on the inbound path, and the traps that have already bitten.
The user-facing page is Scheduled work in your own calendar.
Related: Mailbox Authentication Β· Tickets Β· Architecture
Colour key: βοΈ engine Β· π API Β· π₯οΈ UI Β· β° cron Β· π docs
| π¨ | File | What it does |
|---|---|---|
| βοΈ | includes/calendar_sync/CalendarSyncProvider.php |
the abstract contract every provider implements |
| βοΈ | includes/calendar_sync/MicrosoftCalendarProvider.php |
the Graph implementation |
| βοΈ | includes/calendar_sync/CalDavCalendarProvider.php |
the CalDAV implementation (#133). Signs in as each analyst - see Β§3 |
| βοΈ | includes/carddav.php |
cardDavRequest(), the negotiated-auth DAV transport CalDAV shares with the contact sync |
| π§ͺ | tests/caldav-provider.php |
45 checks against the BaΓ―kal fixture in docker/carddav-test/
|
| βοΈ | includes/calendar_sync/calendar_sync.php |
modes, credentials, connection loading, feed policy |
| βοΈ | includes/calendar_sync/push.php |
FreeITSM β calendar. calendarSyncReconcileTicket()
|
| βοΈ | includes/calendar_sync/pull.php |
calendar β FreeITSM, plus subscription lifecycle |
| βοΈ | includes/ics.php |
RFC 5545 output β escaping, folding, icsRespond()
|
| βοΈ | assets/js/schedule.js |
shared duration/naive-time maths for inbox and calendar |
| π | api/tickets/schedule_ticket.php |
the one write path for a schedule |
| π | api/tickets/get_scheduled_tickets.php |
what the calendar grid draws |
| π | api/tickets/schedule_feed.php |
an analyst's .ics feed |
| π | api/calendar/feed.php |
the Calendar module's .ics feed (same engine) |
| π | api/tickets/calendar_enrolment.php |
an analyst turning it on/off for themselves |
| π | api/system/calendar_sync.php |
admin: connections, mailbox map, policy, health |
| π | api/calendar/graph_notify.php |
Microsoft's change-notification endpoint |
| β° | cron/calendar_sync_pull.php |
renews subscriptions and polls |
| π₯οΈ | system/calendar-sync/index.php |
the admin screen |
| π₯οΈ | system/preferences/index.php |
My work calendar β the analyst's own choice |
| π₯οΈ |
tickets/calendar.php + assets/js/calendar.js
|
the grid, drag-to-reschedule, auto-refresh |
| π₯οΈ |
includes/subscribe_modal.php, assets/js/subscribe.js, assets/css/subscribe.css
|
the shared subscribe dialogue |
| π | tickets/help-calendar-sync.php |
the in-product guide |
β οΈ Editingassets/js/calendar.jsmeans bumping?v=NNintickets/calendar.php. Cached hard; without the bump you test the old file and conclude your change did nothing.
Three tables, all created by freeitsm.sql and by api/system/db_verify.php.
| Table | Holds |
|---|---|
calendar_connections |
any number of rows, one per connection: provider, credentials (or mailbox_id to borrow), allow_feed, last error |
calendar_enrolments |
one row per analyst: mode, connection_id (which connection they use), calendar_address, delta token, subscription id/expiry/secret, last_error
|
calendar_sync_events |
the map: ticket_id + analyst_id β remote_event_id, remote_calendar, and the connection_id that wrote it |
Plus two columns on tickets, added alongside the existing work_start_datetime:
| Column | Why |
|---|---|
work_end_datetime |
Graph cannot create an appointment without an end |
work_all_day |
an all-day job is not a 24-hour block |
π΄
db_verifycreates COLUMNS ONLY β keys come from$uniqueIndexesorfreeitsm.sql. Bothcalendar_enrolments.analyst_idandcalendar_sync_events (ticket_id, analyst_id)need a UNIQUE key, and both are written withON DUPLICATE KEY UPDATE. Without the key that silently becomes an INSERT every time. A fresh install fromfreeitsm.sqlwas fine; an upgraded install accumulated duplicate rows and nothing complained. Registered indb_verify.php:['calendar_enrolments', 'uniq_calendar_enrolment_analyst', '(`analyst_id`)'], ['calendar_sync_events', 'uniq_calendar_sync_ticket_analyst', '(`ticket_id`, `analyst_id`)'],
π΄ Guard every new column behind a schema check. Shipping
work_end_datetimeunguarded broke opening any ticket at all on an install that had not run Database Verification yet βget_email_detail.phpandget_scheduled_tickets.phpboth selected it.scheduleSchemaReady()now gates those reads. The failure mode is nasty because it has nothing to do with calendars: the feature you added is not the feature that breaks.
Tasks reuse all of the above rather than growing a parallel set of tables:
| Change | Why |
|---|---|
calendar_sync_events.ticket_id β nullable
|
a row belongs to a ticket or a task, never both |
calendar_sync_events.task_id |
the other half of that |
calendar_sync_events.kind β work | due
|
one task can produce two events |
calendar_enrolments.task_mode β off | work | due | both
|
what of a task the analyst wants |
task_audit (new table) |
tasks had no history at all |
Tasks needed no new columns of their own: work_start_datetime, work_end_datetime and work_all_day already existed on tasks, in the same shape as on tickets.
π΄
kindis load-bearing, and so is its place in the unique key. A task can hold a work event and a due event at once. The upsert ridesUNIQUE (task_id, analyst_id, kind); dropkindfrom it and the due event finds the work event's row and overwrites it β so an analyst who asked for both gets whichever was reconciled last, and it flips on every save.
π
task_modeis a SECOND value, not more values onmode.modedecides how things reach a calendar (push, feed, off);task_modedecides what. They are independent: folding them together needs eight values to say two things, and switching tickets off would silently take tasks with it.
β οΈ The ticket unique key still holds onceticket_idis nullable. MySQL permits any number of NULLs in a UNIQUE index, so task rows pass through(ticket_id, analyst_id)without colliding on(NULL, analyst). Nothing had to change there.
Relaxing ticket_id on an upgraded install is a probed MODIFY in db_verify.php β the same shape as users.email and emails.from_address. Read IS_NULLABLE first so it fires once and is a no-op afterwards; relaxing NOT NULL cannot invalidate a row that already exists.
Why task_audit had to exist. Due dates are draggable from Outlook by request. A due date is a commitment, often to somebody else, and until this there was no record anywhere of a task changing at all. calendarPullTaskAudit() writes source = 'calendar', so a change made outside FreeITSM is tellable from one made by a person.
CalendarSyncProvider follows the same shape as IssueTrackerProvider and MessagingProvider.
abstract public function createEvent(string $calendarAddress, array $event): string;
abstract public function updateEvent(string $calendarAddress, string $remoteEventId, array $event): void;
abstract public function deleteEvent(string $calendarAddress, string $remoteEventId): void;
abstract public function verifyConnection(): void;
public function verifyTarget(string $calendarAddress): bool;
public function pollChanges(string $calendarAddress, ?string $token): array;
public function createSubscription(string $calendarAddress, string $notifyUrl, string $secret): array;
public function renewSubscription(string $subscriptionId): array;
public function deleteSubscription(string $subscriptionId): void;Two exception types carry meaning the caller acts on:
| Exception | The caller's correct response |
|---|---|
CalendarEventMissing |
somebody deleted it in the calendar β create a fresh one, do not error |
CalendarSubscriptionMissing |
it lapsed β create a fresh one, do not error |
π These are classes rather than error strings because the response is specific. "Not found" is a normal outcome here, not a failure β a user tidying their calendar is expected behaviour, and code that treats it as an error either spams the log or gives up syncing.
verifyConnection() and verifyTarget() are separate on purpose. The admin Test button reports them as two answers: do the credentials work and can this mailbox be reached have entirely different fixes, and merging them into one "it failed" leaves you guessing.
App-only client credentials against /users/<address>/events β no per-analyst OAuth, no consent flow, no refresh tokens. Credentials are usually borrowed from an existing mailbox's app registration (calendar_connections.mailbox_id), so most installs register nothing new.
β οΈ All-day events convert, they do not merely flag. FreeITSM stores an all-day ticket as00:00β23:59so anything ignoring the flag still gets a sensible block. Graph wants a date-only start and an exclusive end:$startDay = substr($event['start'], 0, 10); $endDay = (new DateTime(substr($event['end'], 0, 10)))->modify('+1 day')->format('Y-m-d');Same rule as iCalendar
DTEND. Get it wrong and every all-day job renders a day short.
The one structural difference from Microsoft: there is no app that can write into everybody's calendar. Each calendar belongs to its owner's account, so the provider signs in as the analyst whose event it is touching.
// calendar_sync.php
calendarSyncProviderFor(array $connection, ?array $enrolment = null): CalendarSyncProvider
calendarSyncAccount(array $enrolment): array // ['username' => β¦, 'password' => β¦], decrypted
// the base class
protected $account = [];
public function withAccount(array $account): self
const CAP_NOTIFY = 'notify'; // Microsoft only
const CAP_PER_ANALYST = 'per_analyst'; // CalDAV onlyπ΄ Pass the enrolment wherever an analyst is involved. push.php passes the owner's;
calendarSyncRemoveRow()passes the enrolment of the row's analyst, because only their sign-in can take the event out of their calendar. A CalDAV provider built without one refuses every call with "No calendar sign-in has been saved" rather than guessing. Microsoft ignores the argument.
| Microsoft | CalDAV | |
|---|---|---|
calendar_connections.credentials |
tenant, client id, secret |
server_url, auth - no password
|
calendar_enrolments.calendar_address |
a mailbox address, admin-set | the calendar's URL, analyst-set |
calendar_enrolments.credentials |
unused |
username, password, calendar_name, encrypted (the column was reserved for this) |
| email fallback for the address | analysts.email |
none - calendarSyncEnrolment() returns null until they choose |
remote_event_id |
Graph's id |
freeitsm-<32 hex>, also the UID and the resource name β¦/<id>.ics
|
delta_token |
a Graph deltaLink |
sync:<sync-token> or etags:<json>
|
| notifications | yes | no - CAP_NOTIFY is false, so the cron never tries |
Why the analyst may choose the address here and not with Microsoft. With Microsoft the app's permission reaches every mailbox, so an analyst choosing their own target could write into anyone's. With CalDAV the analyst's own password is the limit. The remaining risk is server-side request forgery - a saved address pointing at the metadata service or an internal page - so calendarUrl() refuses any address not on the configured server's origin (sameOrigin(): scheme, host and port), and eventUrl() refuses any event id FreeITSM did not mint.
Writes. createEvent() PUTs with If-None-Match: *. updateEvent() GETs the event, rewrites only the lines FreeITSM owns (DTSTART, DTEND, DURATION, SUMMARY, DESCRIPTION, URL, DTSTAMP, LAST-MODIFIED, at event level only, never inside a VALARM) and PUTs with If-Match, retrying once on 412. A reminder or note the analyst added survives. Same rule as the contact write-back: never regenerate what you did not create in full. The body has no METHOD - RFC 4791 forbids it in a stored object - so it is built directly rather than with icsHeader().
Reads. sync-collection (RFC 6578) lists hrefs and ETags; only freeitsm-*.ics hrefs are kept, so the analyst's own appointments are never fetched, and ours are read with a calendar-multiget in batches of 50. A server without sync-collection falls back to a Depth-1 PROPFIND of ETags, remembered in the token as JSON.
π΄ sabre/dav does not refuse a token from the future. A token it never issued - what FreeITSM holds after the calendar server is restored from a backup - gets "no changes" and the same token back, for ever, so inbound sync would stop with no error anywhere. So an empty answer that echoes our token is checked against the calendar's own
sync-token; if they differ it asks once more (a change may have landed in between) and then re-baselines. A token in a foreign format is refused by the server withvalid-sync-token, and that re-baselines too.
Times. eventTimes() reads UTC (Z), named zones (TZID) and floating times, converts to FreeITSM's wall clock, and turns a VALUE=DATE pair into 00:00 to 23:59:59 of the last day (DTEND is exclusive). A zone PHP does not know - Outlook's Windows names - is read as local rather than dropped.
Discovery. current-user-principal, then calendar-home-set, then a Depth-1 listing. Only collections that are calendars, hold VEVENT and are writable are offered, and only on the configured origin.
There is no "the" connection. calendar_connections holds as many rows as the admin adds, of either provider; each enrolment names its own (calendar_enrolments.connection_id) and each event names the one that wrote it (calendar_sync_events.connection_id).
// calendar_sync.php
calendarSyncConnectionIds(PDO $conn): array // active ids, oldest first
calendarSyncOnlyConnection(PDO $conn): ?array // the one connection, or null when there are 0 or 2+
calendarSyncConnectionFor(PDO $conn, array $enrolment): ?array // theirs, else the only one
calendarSyncConnectionProvider(PDO $conn, ?int $id): string // cached per requestπ "The only connection" is a fallback, never a guess. An enrolment that names no connection works on a single-connection install and on nothing else. So
api/system/calendar_sync.phprunscsAdoptOrphans()before it adds, resets or removes anything: while there is still exactly one connection it writes that id onto every enrolment and event that only implied it. Without that, adding a second connection would silently stop everybody who enrolled before connections could be chosen.
π΄ Remove through the connection that WROTE the event.
calendarSyncRemoveRow()loads the row'sconnection_id, not the analyst's current one. And push.php has a backstop: if the analyst's mapped row belongs to a different connection or address from the one they use now, it is removed and written again rather than updated in the wrong place.
Changing a connection (action=save with an id). Microsoft β CalDAV, or a CalDAV server on a different origin, is a reset of that connection only: needs_confirm with counts scoped to its connection_id, then csTakeBack() removes its events with the old settings, deletes its analysts' Microsoft subscriptions, and resets its enrolments (push off; address, credentials, token, subscription and connection_id cleared). Analysts on other connections are untouched. Deleting a connection does the same, then deletes the row.
An analyst changing connection (api/tickets/calendar_enrolment.php, connection_id on the POST). enrolSwitchConnection() takes their events back through the old connection, deletes a Microsoft subscription, and clears the sign-in and token - and the address whenever either side is CalDAV. It only runs once the new target is proven: a Microsoft target's mailbox is verified first, and a CalDAV target answers needs_account until caldav_save succeeds there. A failed check moves nothing. A saved CalDAV password is never carried to another connection. With several connections and none chosen, mode=push answers needs_connection, and Preferences shows a Which calendar chooser.
set_address (admin) is refused for an analyst whose connection is CalDAV, and verified against their Microsoft connection, or the first Microsoft one when they have not chosen.
calendarSyncReconcileTicket($conn, $ticketId, $gone = false) is the only entry point. It does not take an instruction like "create" or "delete". It works out what should exist for that ticket and makes it true.
That is what makes the awkward cases fall out for free rather than each needing its own branch:
| What happened | What reconcile does |
|---|---|
| scheduled for the first time | no row β create |
| rescheduled | row exists β update |
| reassigned to another analyst | old analyst's row no longer should exist β delete there, create in the new calendar |
| unscheduled / closed / deleted | nothing should exist β delete |
| analyst opted out | calendarSyncRemoveAllForAnalyst() |
| user deleted it in Outlook |
CalendarEventMissing β create a fresh one |
π A reaction-based design needs a code path per transition and grows one every time somebody invents a new way to change a ticket. Reconcile has one path and a single question: what should be there now?
Because every schedule write goes through api/tickets/schedule_ticket.php, a drag on the calendar, the inbox modal and the REST API all reconcile identically. Dragging is not a second way to write a schedule; it is a different way to say the same thing.
β οΈ updateTicket()takesassigned_analyst_idand derivesowner_id. Passingowner_idis silently ignored β three reassignment tests "passed" while changing nothing.
calendarSyncReconcileTask($conn, $taskId, $gone = false) is the same shape, with one difference: it computes the wanted set per kind, because a task can want a work event, a due event, both or neither. Narrowing both β due must lose the work event rather than orphan it, and that falls out of the same comparison.
Hooked from TasksService at create, update, kanban move and delete β the move included, because dragging a card into a closed column is the commonest way a task gets completed and it dispatches no workflow event.
π΄ DELETE RECONCILES BEFORE THE ROW GOES.
calendar_sync_events.task_idisON DELETE CASCADE, so the moment the task is deleted the mapping rows vanish β and those rows are the only record of which event, in whose mailbox, to remove. Reconciling afterwards finds nothing and leaves an appointment in somebody's calendar that FreeITSM can no longer touch.deleteTask()walks the whole subtask tree first, calling reconcile with$gone = truefor each.
π΄ ALL-DAY IS STORED ON ONE DAY, 00:00β23:59:59. Both serializers convert that to the exclusive end their format wants:
icsEvent()adds a day forDTEND;VALUE=DATE, andtoGraphEvent()adds a day forisAllDay. Hand them a day-after end and the day gets added twice β a task due on the 5th arrives spanning the 5th and 6th. Shipped exactly that; Ed found it on a real task. The conversion belongs to whoever writes the wire format, never to the caller.The test that was supposed to catch it asserted the bug, because it checked my reasoning rather than the outcome. It now runs the real
icsEvent()and measures the span.
β οΈ NotTicketsService::SCHEDULE_DEFAULT_MINUTESdirectly.includes/services/tickets.phprequirespush.php, so requiring it back is circular β and a task save path may never have loaded it. An undefined class raises anError, which thecatch (Exception)around the reconcile does not catch, turning a missing calendar entry into a fatal on saving a task. Read it viaclass_exists()and fall back.
calendarSyncPullForAnalyst() reads a Graph delta query and applies what changed. Everything here is about not trusting the answer too much.
If there is no delta token, the sync takes a baseline and applies nothing. An empty or wholesale-different answer can mean everything was deleted or I have no idea where I am, and only one is safe to act on. The cron reports baseline taken (nothing applied) explicitly, because "I have just learned where I am" and "nothing changed" are different states and only one is worth worrying about twice.
const CALENDAR_DELETE_CAP = 5;More than five removals in one poll is refused and reported, not obeyed. Mass deletion is far more likely to be a fault β a mailbox move, a retention policy, a bad token β than an instruction.
calendarPullAudit() writes every inbound change to the ticket history, naming the calendar it came from. A ticket must never silently move with no record of why.
On top of those, accepting deletions at all is opt-in (tickets_calendar_accept_deletes, default off).
π Echo suppression is by COMPARISON, not by marker. After FreeITSM writes an event, Graph reports it as changed. Rather than tagging events as "ours" β which fails the moment anything else touches them β the pull compares the incoming value against what the ticket already says and does nothing when they match. Idempotent, and it cannot be defeated by an edit that strips a marker.
The lookup is two queries, not one join β a row belongs to a ticket or a task, so a single query would need an outer join to both tables and a pile of COALESCE to work out which columns meant anything. Each carries an explicit _entity instead, and every branch reads that rather than re-deriving it from which column happens to be null.
| Kind | Applied by | Writes |
|---|---|---|
| ticket | calendarPullApply() |
tickets.work_* |
task work
|
calendarPullApplyTaskWork() |
tasks.work_* |
task due
|
calendarPullApplyTaskDue() |
tasks.due_date |
π΄ A due event compares by DATE, not by start/end. It is an all-day banner mapping to a date column. Compare it like a work slot and every refresh looks like a move, so the due date is rewritten on every single poll β the echo suppression above only works if you compare the same shape of thing you stored.
Deleting a due event clears the due date; deleting a work event clears the window. Both are behind the existing accept-deletes setting, which is off by default, and both write a task_audit row.
cron/calendar_sync_pull.php does both jobs: renews subscriptions, then polls.
π΄ Notifications are an accelerator, never a replacement. Graph caps calendar subscriptions at ~3 days, so without the cron they lapse. And a missed notification is indistinguishable from nothing having changed β so the poll stays as the backstop unconditionally.
Renewal happens with six hours of slack rather than at the last moment: a blip in the cron then costs nothing.
api/calendar/graph_notify.php is public and unauthenticated by necessity β Microsoft's servers cannot carry a session. Three things make that safe:
-
clientStateβ a random secret per subscription, compared withhash_equals, rotated whenever a subscription is recreated. - The body is never trusted for content. A notification says only this subscription saw a change; what changed is then read from Graph with our own credentials. A forged notification can at worst cause a poll of a calendar we already sync.
- Nothing writes a ticket directly β it calls the same pull path as the cron, guards and all. A notification changes when we look, never what we are willing to do.
The endpoint answers 202 and calls fastcgi_finish_request() before doing any work, because Graph retries if it does not get an answer within about three seconds β which would mean polling the same calendar several times over.
When a subscription is created, Graph immediately POSTs with ?validationToken=β¦ and an empty body declared Content-Type: text/plain, expecting the token echoed back within ten seconds.
includes/request_guard.php β reached via functions.php, and running on include β answers 415 to exactly that, because no browser has an honest reason to send text/plain and it is the one CORS-simple type that dodges a preflight.
Two correct pieces of code colliding. The symptom is only ever seen from Microsoft's side:
Subscription validation request failed. HTTP status code is 'UnsupportedMediaType'.
The handshake must therefore run BEFORE the require lines, not merely first in the file. That is safe because the branch does nothing: no session, no database, no cookie read, no state changed. It echoes one query parameter as inert text/plain (with nosniff) and exits. Real notifications arrive as application/json and pass the guard normally.
β οΈ Test the handshake with a POST, not a GET. A GET sails through and proves nothing β it does not carry theContent-Typethat triggers the guard. This is the only place in the product where atext/plainbody is legitimate; the other eight unauthenticatedphp://inputendpoints all receiveapplication/jsonorx-www-form-urlencoded.
includes/ics.php is the shared RFC 5545 writer, used by both the tickets feed and the Calendar module's.
| Rule | Why it bites |
|---|---|
| CRLF line endings | required by spec; bare \n is rejected by strict parsers |
| 75-octet line folding | octets, not characters β folding mid-UTF-8-sequence corrupts it |
DTEND is EXCLUSIVE for all-day |
an all-day event on the 5th ends on the 6th β and icsEvent() does that conversion itself, so callers pass the same day at both ends. Passing the day after adds it twice. |
Escape , ; \ and newlines |
an unescaped comma silently splits a field |
Feed access is a capability URL β a secret token in the query string. Hence the "treat it like a password" copy, the HTTPS warning, and Reset issuing a new token rather than editing an ACL.
Policy lives in calendar_sync.php: FEED_MODE_OFF / FEED_MODE_REF (numbers only) / FEED_MODE_FULL. scheduleFeedDetail() resolves the admin ceiling against the analyst's own choice β the analyst can be more private than the ceiling, never less.
The feed emits tasks too, driven by the same task_mode the push route uses β two answers to one question is how the two routes drift apart. FEED_MODE_REF numbers tasks (Task #123) rather than naming them, on the grounds that a task title is no less revealing than a ticket subject.
π΄ The uid carries the kind:
freeitsm-task-work-<id>andfreeitsm-task-due-<id>. Two VEVENTs sharing a uid are one event to every calendar client, so a task with both dates would show only whichever came last in the file.
api/system/calendar_sync.php returns last_poll_minutes alongside the per-analyst subscription state.
π This exists because a stopped cron is the one failure that never announces itself. A broken connection errors. A subscription that will not create errors. A scheduled job that has stopped looks exactly like a calendar in which nothing changed, and stays looking healthy for weeks while every inbound change is lost.
Measured in SQL, not against the browser clock:
SELECT TIMESTAMPDIFF(MINUTE, MAX(delta_synced_datetime), NOW())
FROM calendar_enrolments
WHERE mode <> 'off' AND delta_synced_datetime IS NOT NULLdelta_synced_datetime is written with NOW(), so comparing it to NOW() keeps both sides on one clock and sidesteps the timezone question entirely.
Warning threshold is 30 minutes, worded as a fact plus where to look β never a bare "problem" β because FreeITSM cannot know what interval was chosen and an admin polling hourly on purpose should recognise their own decision.
β οΈ A success must clearlast_error. It was written on failure but only ever cleared by a successful push, so a system that failed once and then recovered kept a red pill up indefinitely.calendarStoreSubscription()β the success path for both create and renew β now clears it. A stale error is worse than none: it sends you hunting a fault already fixed.
π΄ FreeITSM scheduling times are NAIVE wall-clock values. Never round-trip one through
toISOString(). It treats the value as local and emits UTC, so re-opening a ticket scheduled just after midnight showed the previous day for anyone ahead of UTC β and saving accepted the wrong date.
assets/js/schedule.js holds the shared maths β parseNaive, formatNaive, toStoredRange, durationMinutes β used by both the inbox modal and the calendar panel, so the two screens cannot drift.
Duration is asked for, an end is stored. Asking "how long?" rather than "what time does it finish?" makes an end-before-start inexpressible rather than something to validate afterwards. A NULL end is resolved to the default on read, so nothing needed backfilling and no existing ticket was altered.
β οΈ Test timezone conversions on a date where the zones actually differ. An assertion written in March β when Europe/London is UTC β cannot fail. This one got past me twice.
tickets/calendar.php + assets/js/calendar.js.
Auto-refresh polls every 10 seconds but repaints only when a signature of the rendered fields changes. A poll that rebuilt the grid on every tick would drop hover states, close tooltips and flicker the month six times a minute, almost always to draw exactly what was already there. The signature is sorted, so the server returning the same tickets in a different order is not mistaken for a change.
Four situations skip a refresh entirely rather than queueing it:
| Skipped when | Because |
|---|---|
| a modal is open |
showTicketDetail holds a reference into scheduledTickets, and a refresh replaces that array β the modal would edit an object no longer connected to the grid |
| mid-drag | re-rendering destroys the dragged element |
| a drag is saving |
dragTicketId is already null by then, so it needs its own flag β see below |
| the tab is hidden | otherwise every open calendar in the building polls all day for nobody |
β οΈ dragTicketIdcannot guard the save.dragendfires on mouse release, long before the server answers.applyDrag()paints optimistically and rolls back on refusal, so a refresh landing in that window would either overwrite the optimistic paint or resurrect the rolled-back state. It setscalendarRefreshBusyacross the whole save.
Owner colours are assigned by POSITION, not id % palette.length. Analysts 1 and 41 both landed on the same green while the legend confidently showed them as separate people β any two ids ten apart collided.
β οΈ let/constat a script's top level are NOT window properties. A harness readingw.scheduledTicketsgetsundefinedβ which can look like a passing assertion depending how it is written. Usew.eval('scheduledTickets.length').
β οΈ Headless Chrome never runs CSS transitions. Computedvisibilityand bounding rects lie; trustclientHeightvsscrollHeight. Use--headless=old --enable-logging=stderr --log-level=0, drive the real page in a same-origin iframe, and always include a negative control β an assertion that must fail. Several "passing" suites here turned out to be incapable of failing.
Useful proofs when changing this area:
- Stamp a marker node into the grid: it must survive a refresh that finds no change and be destroyed by one that does.
- Test the Graph handshake with a genuine
POST β¦ Content-Type: text/plain, Content-Length: 0. - Age
delta_synced_datetimeto prove the stale-health branch renders β the guidance note only exists inside that branch. - Break the credentials deliberately, then fix them, and check the error pill actually clears.
tests/calendar-sync-tasks.php β 31 assertions, no network. It covers what the code decides: what each task_mode means, that an unrecognised mode is not a licence to publish, the shape of both events, and that reconcile is a safe no-op with no connection configured.
Two lessons from writing it, both of which produced confident wrong answers:
π΄ A test of your own reasoning proves nothing. The all-day assertion checked "the end is the day after" β my premise, not the outcome β and passed happily while every deadline in Outlook was a day too long. It now runs the real
icsEvent()and measures the span, and does the Graph provider's arithmetic explicitly.
π΄ A harness must not depend on a live setting to stay harmless. The sample task was assigned to a real analyst and asserted that reconcile "writes nothing" β true only while nobody had switched tasks on. The moment somebody did, the test pushed a fake task into a real mailbox, and its cleanup deleted the task, cascading the mapping row away and orphaning the event. The sample task is now assigned to nobody, so it cannot reach a calendar whatever anyone has chosen β with a positive control proving the assertion is about the assignee and not a broken reconcile.
For the live end, read the mailbox back rather than trusting the write: GET /users/<addr>/calendarView with $select=subject,isAllDay,start,end. That is what showed Due: Tidy server room running 2026-09-05 β 2026-09-07, and what confirmed the fix.
docker compose -f docker/carddav-test/docker-compose.yml up -d
bash docker/carddav-test/seed.sh # users itsm/itsm and tech2/tech2, each with a Work calendar
php tests/caldav-provider.php # 45 checks, no databaseThe provider test needs no FreeITSM database and cleans up after itself. It covers the reminder surviving an update, the analyst's own appointments never being reported, tokens (refused, from the future, malformed - each a baseline, with a positive control that a current token is not), the ETag fallback, the origin guard, and one analyst being unable to touch another's calendar.
π΄ Test the whole feature in the Docker clean room, never on a real install. Changing or deleting a Microsoft connection takes its events back out of real Outlook calendars - which is correct, and is exactly why it must not be done on a machine with a live Microsoft connection.
docker compose up -d --buildgives an app on 8080 that reaches BaΓ―kal athttp://host.docker.internal:8092/dav.php/. Sessions go in the container's/tmp.