Ticket Calendar Sync Developer Guide - edmozley/freeitsm GitHub Wiki

Scheduled work in your own calendar β€” Developer Guide

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


1. πŸ“ The files involved

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

⚠️ Editing assets/js/calendar.js means bumping ?v=NN in tickets/calendar.php. Cached hard; without the bump you test the old file and conclude your change did nothing.


2. Schema

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_verify creates COLUMNS ONLY β€” keys come from $uniqueIndexes or freeitsm.sql. Both calendar_enrolments.analyst_id and calendar_sync_events (ticket_id, analyst_id) need a UNIQUE key, and both are written with ON DUPLICATE KEY UPDATE. Without the key that silently becomes an INSERT every time. A fresh install from freeitsm.sql was fine; an upgraded install accumulated duplicate rows and nothing complained. Registered in db_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_datetime unguarded broke opening any ticket at all on an install that had not run Database Verification yet β€” get_email_detail.php and get_scheduled_tickets.php both 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 (#75, second pass)

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.

πŸ”΄ kind is 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 rides UNIQUE (task_id, analyst_id, kind); drop kind from 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_mode is a SECOND value, not more values on mode. mode decides how things reach a calendar (push, feed, off); task_mode decides 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 once ticket_id is 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.


3. The provider contract

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.

Microsoft specifics

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 as 00:00–23:59 so 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.

CalDAV specifics (#133)

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 with valid-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.

Any number of connections

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.php runs csAdoptOrphans() 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's connection_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.


4. Push: reconcile, don't react

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() takes assigned_analyst_id and derives owner_id. Passing owner_id is silently ignored β€” three reassignment tests "passed" while changing nothing.

The task twin

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_id is ON 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 = true for 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 for DTEND;VALUE=DATE, and toGraphEvent() adds a day for isAllDay. 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.

⚠️ Not TicketsService::SCHEDULE_DEFAULT_MINUTES directly. includes/services/tickets.php requires push.php, so requiring it back is circular β€” and a task save path may never have loaded it. An undefined class raises an Error, which the catch (Exception) around the reconcile does not catch, turning a missing calendar entry into a fatal on saving a task. Read it via class_exists() and fall back.


5. Pull: three guards

calendarSyncPullForAnalyst() reads a Graph delta query and applies what changed. Everything here is about not trusting the answer too much.

Guard 1 β€” never act on a lost baseline

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.

Guard 2 β€” a deletion cap

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.

Guard 3 β€” audit everything

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.

Pulling tasks

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.


6. Subscriptions and the notification endpoint

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:

  1. clientState β€” a random secret per subscription, compared with hash_equals, rotated whenever a subscription is recreated.
  2. 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.
  3. 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.

πŸ”΄ The trap: the CSRF guard blocks Graph's handshake

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 the Content-Type that triggers the guard. This is the only place in the product where a text/plain body is legitimate; the other eight unauthenticated php://input endpoints all receive application/json or x-www-form-urlencoded.


7. The ICS feed

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> and freeitsm-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.


8. Health, and the failure that stays quiet

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 NULL

delta_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 clear last_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.


9. Time handling

πŸ”΄ 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.


10. The calendar grid

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

⚠️ dragTicketId cannot guard the save. dragend fires 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 sets calendarRefreshBusy across 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.


11. Testing notes

⚠️ let/const at a script's top level are NOT window properties. A harness reading w.scheduledTickets gets undefined β€” which can look like a passing assertion depending how it is written. Use w.eval('scheduledTickets.length').

⚠️ Headless Chrome never runs CSS transitions. Computed visibility and bounding rects lie; trust clientHeight vs scrollHeight. 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_datetime to 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.

Testing the task side

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.

Testing CalDAV

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 database

The 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 --build gives an app on 8080 that reaches BaΓ―kal at http://host.docker.internal:8092/dav.php/. Sessions go in the container's /tmp.

⚠️ **GitHub.com Fallback** ⚠️