Skip to content

Mobile Friendly Calendar

Ed Mozley edited this page Aug 9, 2026 · 4 revisions

Mobile: Calendar

Calendar is the third module made mobile‑friendly, after Tickets and Assets. Same hard rule as both: everything lives inside one @media (max-width: 768px) block and one matchMedia‑gated script, so desktop is byte‑identical.

🧰 For the code‑level catalogue of the CSS/JS tricks used here β€” the iOS reflow trap, contained horizontal scrollers, wrap‑don't‑edit, headless verification β€” see Mobile: Techniques & Tricks.

Why Calendar? It is the module that most obviously belongs on a phone β€” certificate expiries, contract renewals and maintenance windows are things you check away from a desk β€” and it already ships a Add to your phone subscription feature. A calendar that tells you to put it on your phone, and then can't be read on one, is not finished.

Shipped in one round: #998, plus #999, a fix to the shared modal rules that this work uncovered.


Scope

Page State
calendar/ β€” the month / week / day grid Done deep β€” dot grid + day agenda, contained week scroller, sheets, reflowed forms
calendar/table/ β€” full‑screen table view Done β€” inherits LAYER 15c (the shared .dt-* engine) for the cost of two <link> lines
calendar/settings/ Done β€” LAYER 15e for the page frame, 16i for the categories table, which 15e doesn't reach
calendar/help.php Done β€” the guide's body was already responsive (help‑page house style); it needed the app shell and a scroll container (16h)

The table/ row is the point of building 15c generically in the Assets round: a third module's table view cost no new CSS at all.

⚠️ The settings and help rows are the cautionary half of that story. Both were opted in first as "inherits an existing layer, done" β€” and both were wrong, in the same way. See when "inherited" isn't "done".


The decision this module turns on: what a month grid becomes

Seven columns of a 360px screen is a ~50px cell. The desktop month view puts up to three text pills in each one, so on a phone every event reads as three letters and an ellipsis β€” and six rows of them are also the widest thing on the page, which is what detonates the media query.

Three options were put up:

Option What you lose
Grid of coloured dots, tap a day for an agenda Event names aren't visible without a tap
Agenda feed instead of a grid The month‑at‑a‑glance overview, and tap‑an‑empty‑day‑to‑add
Both β€” a small dot grid with a permanent feed beneath Screen space; roughly double the code

Chosen: dots + a tapped‑day agenda. It is what every phone calendar does, and it keeps the one thing a month grid is for β€” seeing which days are busy β€” while the agenda carries everything the dots can't say.

This is the same shape of decision as Assets round 2 ("columns known β†’ stop being a table"), arrived at from the other direction: the grid is worth keeping, so make its contents smaller than text.

The two halves have to be designed together

Dots are only acceptable because tapping the day answers them. So the two changes are one change:

  • CSS turns each pill into a 7px dot β€” it keeps its inline background-color (the category colour) and loses everything else. text-indent: -999px pushes the title out of the box rather than hiding it, so the title stays in the accessibility tree.
  • pointer-events: none on the dot is the load‑bearing line. A 7px dot is not a tap target; making it inert lets the tap fall through to the day cell, which is a 50Γ—54px one.
  • The day cell's existing onclick is openEventModal(null, dateStr) β€” so the agenda is wired by wrapping that one function, with no change to the renderer at all.

What's been done

  • Month β†’ dots + agenda, as above. The agenda sheet lists every event on the day (the grid caps at three dots plus a +N), each with its time, location and category, and carries its own + New event button pre‑filled with that day.
  • Week keeps its real shape and scrolls sideways. A time gutter plus seven day columns cannot be made to mean anything at 360px β€” squeezed to 43px each they're unreadable. It gets an honest min-width: 620px and scrolls inside .calendar-grid. Header and body share that one scroll container, so the day columns stay aligned with the hours beneath them.
  • Week and day open at 7 AM. Both are 24 rows of 60px and open at the top, so a phone landed on midnight β€” three screens above anything that happens in a working day. On a desktop pane you at least see through to ~10 AM; at 360px you saw 12 AM to 6 AM and nothing else.
  • The 250px sidebar becomes a sheet. The whole .calendar-sidebar node is relocated into a .mobile-sheet (so #categoryFilterList keeps its id and renderCategoryFilters() still finds it), reached from a βš™ Categories button. The subscribe panel rides along inside it.
  • A sub‑bar carrying the two actions the hidden sidebar owned: βš™ Categories and a filled + New event pill.
  • The quick‑view popup becomes a bottom sheet β€” with no JS wrap at all, see below.
  • The event form fills the screen, one field per row, with finger‑sized native date and time pickers.
  • The device back button closes a sheet β€” each open pushes a history entry, the same move that makes the ticket pane stack feel native.
  • Every :hover affordance is neutralised on the grid. A tint or lift on hover sticks after a tap on a touchscreen and reads as a stuck selection.
  • Settings: the categories list stops being a table β€” one card per category, name and swatch prominent, description subordinate, status as its badge. ⚠️ Superseded by #1004: every settings table now scrolls sideways instead, so that all settings screens behave alike. The reasoning below is kept because it is still the right call for a 4-column self-describing table in isolation β€” it lost to consistency across the set, not to being wrong.
  • Help: the app shell, plus the scroll container LAYER 2 obliges every opted‑in page to declare.

When "inherited" isn't "done"

Both of the module's secondary pages were opted in on the strength of an existing layer, and both looked finished by the numbers. Neither was.

Settings. LAYER 15e handles any page built on .container + renderSettingsTabBar, which this is β€” so two <link> lines and a data-mobile-page="settings" marker, and the harness reported docScrollW === innerWidth and nothing uncontained. It was still a four‑column table you had to drag sideways to read. Two things had gone unnoticed:

  • 15e's table rule targets .settings-section-body table. This page puts a .lookup-table straight inside a .tab-content, so the rule never matched.
  • The page's own CSS gives .container an overflow-y: auto, and per the CSS spec a non‑visible value on one axis computes the other to auto β€” so the container was silently a horizontal scroller. The table genuinely was contained. The measurement was right; the conclusion drawn from it was not.

πŸ”‘ docScrollW === innerWidth proves the page won't reflow to desktop. It does not prove the page is usable. Containment is the floor, not the finish line β€” the check exists to catch the iOS trap, and it cannot tell a deliberate scroller from a table nobody can read. Look at the page.

Help. The house style rework (#976) made every guide's body responsive at 900px, so the guide itself was fine. What no help page had was the app shell β€” the header's view links were an inline row spilling off the right edge, because nothing had turned them into a drawer. Linking mobile.css/mobile.js was most of the fix; the rest was one rule, below.

πŸ”‘ Every page opted into mobile.css must declare its own scroller. LAYER 2 makes <body> a 100dvh flex column, and flex items shrink by default β€” so a page taller than the screen is squashed and clipped rather than scrolling. Tickets has .main-container, Assets has .container / .dt-page / .dashboard-page, and help needed .help-container { flex: 1 1 auto; min-height: 0; overflow-y: auto }. Forget it and the page silently loses everything below the fold. It is the same rule as the #937 width pin, on the other axis.


The trap everything here is dodging

The governing rule, in full in Techniques Β§3: iOS sizes the layout viewport to fit its widest content. Anything wider than the screen makes Safari reflow the whole page to a desktop width, the max-width: 768px query stops matching, and every mobile rule switches off at once. It doesn't look like an overflow; it looks like "mobile mode broke."

The Calendar had three things that would have sprung it, and each got a different answer:

Too wide Answer
A month row of seven text pills Reflow the content β€” pills become dots
A week grid of eight columns Give it a bounded scroll container of its own
Native date/time fields under 16px (iOS zooms on focus) Force 16px β€” and that turned out to be broken app‑wide, see next section

#999 β€” the anti-zoom rule that was never reaching the fields it was written for

LAYER 3 has carried this since the tickets rollout:

.modal-content .form-input,
.modal-content .form-textarea,
.modal-content .form-select { font-size: 16px; }

Measuring the event form found the date field computing to 14px. The cause is specificity, not source order:

/* inbox.css:2653 β€” (0,3,1) */
.modal .form-group input[type="date"] { font-size: 14px; }
/* mobile.css LAYER 3 β€” (0,2,0). Loses, whatever order the files load in. */

So every native date / time / number / email field and every <select> in every modal in FreeITSM has been 14px on a phone β€” not just the Calendar. The ticket snooze panel is the one where it had been noticed: #767 fixed the width of those same inputs, and the font‑size problem was hiding behind that fix.

The correction mirrors the inbox selector list exactly, so it matches on specificity and wins on load order (mobile.css is linked last). Anything broader would have needed !important.

πŸ”‘ The lesson worth keeping: a rule that "already covers this" is worth measuring, not assuming. The rule existed, was correct, was well commented, and had never applied to half the fields it named.


How it works

Entry point

// after the shared shell, before the assets branch's neighbours
if (document.getElementById('calendarGrid')) { initCalendarMobile(); return; }

Guarded on #calendarGrid, not .calendar-container β€” the module's table and settings pages share the header and its stylesheet but have no grid to drive. Same class of trap as the servers page in Assets: a page that shares a class is not the page you meant.

Wrapping openEventModal

The whole agenda hangs off one wrap. No line of itsm_calendar.js changes:

var _openEventModal = window.openEventModal;
window.openEventModal = function (eventId, dateStr, hour) {
    // Only the month grid's day-cell click is redirected: it is the one call
    // that means "I tapped a day". Every other caller passes an id (edit), an
    // hour (a week/day time slot) or nothing (New event) and goes straight through.
    if (mq.matches && !eventId && dateStr &&
        (hour === null || hour === undefined) &&
        typeof currentView !== 'undefined' && currentView === 'month') {
        openDaySheet(dateStr);
        return;
    }
    return _openEventModal.apply(this, arguments);
};

The precision of that condition is the whole design. One function serves four callers, and only one of them means "I tapped a day":

Caller Args Mobile behaviour
A month day cell (null, '2026‑08‑11') Agenda sheet
A week/day time slot (null, '2026‑08‑11', 14) Straight through β€” an hour is a deliberate create
Sidebar / sub‑bar New event () Straight through
editEventFromPopup() (id) Straight through

Reading the module's state works the same way as the inbox: currentView and events are top‑level lets in a classic script, so they're readable here as bare identifiers (typeof‑guarded), while openEventModal, getEventsForDate and formatEventTime are function declarations and therefore window properties that can be read and wrapped.

Re-anchoring an inline-positioned popup

showEventPopup() sets top/left inline from the click coordinates. That's meaningless on a phone, and a 300px box positioned from a tap near the right edge hangs off the screen. It needs no JS wrap, because author !important beats an inline style:

.event-popup {
    top: auto !important;  bottom: 0 !important;
    left: 0   !important;  right:  0 !important;
    width: auto !important;
    z-index: 1600;                 /* clears the 1500 sheet it opens over */
    border-radius: 14px 14px 0 0;
}

πŸ”‘ A JS‑positioned element can often be re‑positioned entirely in CSS. Worth reaching for before wrapping the function that positions it.

Relocating the sidebar β€” and putting it back

The sidebar is moved into the sheet lazily on first open, and moved back when the viewport leaves mobile:

function syncCalendarBar() {
    var on = mq.matches;
    bar.style.display = on ? 'flex' : 'none';
    if (!on) { hideSheets(); sidebarBackToPage(); }
}

This is stricter than the tickets/assets precedent, which relocates one‑way. It matters here because 16a hides the sidebar in the container: without the restore, resizing a desktop browser down through the breakpoint and back up would strand the filters inside a hidden sheet.

mobile.css layers

Layer Purpose
1–13 Tickets β€” see Mobile: Tickets
14–15 Assets β€” see Mobile: Assets
16 Calendar β€” 16a shell Β· 16b month dots Β· 16c week scroller Β· 16d day density Β· 16e popup β†’ bottom sheet Β· 16f event + subscribe modals Β· 16g the two sheets Β· 16h the help guide Β· 16i the settings .lookup-table

16i is keyed on .lookup-table, not on this page. That is the shared settings‑table idiom β€” change‑management, morning‑checks and tasks all use it β€” so those three inherited it the moment they opted in (until #1004 replaced the card feed with a scroller for all of them), the same way 15c was named for the shared .dt-* engine rather than for Assets. Two of its rules (.section-header, .add-btn) reach the already‑shipped Assets settings page, deliberately: same classes, same benefit, and the Add button there is now a 40px tap target too. That page is in the regression sweep for exactly this reason.

Opting a page in

    </style>
    <link rel="stylesheet" href="../assets/css/mobile.css?v=33">   <!-- AFTER the page's own <style> -->
</head>
...
    <script src="../assets/js/mobile.js?v=16"></script>            <!-- last -->
</body>

⚠️ mobile.css and mobile.js are shared. Bump the ?v= on every page that links them β€” ten pages now β€” not just the one you edited, or a page will run the stale file against the new one.


Challenges & solutions

Challenge Solution
.calendar-header is two different elements: the module header bar (<div class="header calendar-header">) and the calendar's own nav strip. Every nav‑strip rule is scoped .calendar-main .calendar-header. An unscoped rule restyles the app header on every calendar page. Grep a class before you style it.
A 7px dot is not a tap target, but it sits on top of the day cell that is. pointer-events: none on the dot; the tap falls through to the cell.
The month cap is three dots, so a busy day under‑reports. The +N more element stays (also inert) β€” and the agenda behind the tap has no cap.
Header and body of the week grid scrolling separately would drift the day columns out of line with the hours. One scroll container: overflow-x on the shared parent .calendar-grid, min-width on .week-grid.
Desktop reserves a 17px scrollbar gutter on the week header so its columns align with the scrolling body. Mobile scrollbars are overlays with no width. .week-header { overflow-y: hidden } β€” the reservation becomes a 17px misalignment on a phone.
LAYER 3 stacks two‑column modal rows with grid-template-columns: 1fr β€” but .form-row in this app is a flex row, so the rule is inert and Start date / Start time stayed side by side. flex-direction: column, scoped to #eventModal rather than widening the shared rule, so no already‑shipped modal changes shape.
The sheet said "Categories" twice β€” its own title, and the relocated panel's heading. Hide only the duplicate: .sidebar-section:has(#categoryFilterList) h3. The "Add to your phone" heading below is not a duplicate and must stay β€” so :first-child would have been wrong.
An empty day wants a "No events" line, but that is a new string, and an EN‑only key falls back silently in the other 23 locales. No new keys anywhere in this work. The empty agenda shows its date heading and the add button, which says it well enough. Every label reuses an existing key (calendar.sidebar.categories, calendar.sidebar.new_event, calendar.subscribe.close).
The agenda's date heading needs a localised weekday and month. toLocaleDateString(document.documentElement.lang, …) β€” correct in all 24 locales, and needs no keys. Notably better than the module's own hardcoded English DAYS/MONTHS arrays; see rough edges.
The settings categories table has a loading row and an empty row that are a single <td colspan="4"> β€” which is both the first and the last cell, so the "pin the actions cell to the card corner" rule would have positioned the empty state out of the card and made it vanish. td:last-child:not(:first-child). A one‑cell row is never "the actions cell".
The help guide's page links were 28px tall and its contents strip showed a scrollbar directly under the header, reading as a stray rule. 40px minimum on the links; scrollbar-width: none plus overscroll-behavior-x: contain on the strip so the swipe doesn't chain to the page.

Verification

Self‑verified with the headless harness from Techniques Β§8, and this round went further than the Assets one: the harness drove the real, authenticated page, not hand‑written markup.

  • A forged session (c:/wamp64/tmp/sess_<id>) planted as a cookie by a same‑origin bootstrap page, so the iframe loads calendar/ for real β€” real PHP, real API calls, real cascade.
  • Fake events with no database write. getEventsForDate is a function declaration, so overriding it on the iframe's window is picked up by the module's own renderers. Full data, no test rows to clean up afterwards, no repeat of the "don't perform a real write against live data" lesson.
  • Driven, not just rendered. The harness clicks a real day cell, clicks a real agenda row, calls setView('week'), opens the options sheet, opens the category modal, and swaps in an empty‑state row β€” and asserts on what happens.
  • Measured, not eyeballed. docScrollW === innerWidth at every state, plus every element whose right edge passes the viewport. ⚠️ The detector needed three corrections before it was honest: elements inside an overflow: hidden ancestor, inside a side‑scroller, or under a position: fixed ancestor are contained and cannot widen the viewport β€” LAYER 2 parks the ticket panes with transform, and LAYER 9 parks the closed Ask‑AI panel at right: -100%. A detector that flags those is crying wolf on shipped, correct code.
  • A desktop positive control at 1100px asserting the inverse of every mobile claim: sub‑bar hidden, sheets hidden, sidebar still 250px and still in the page, pills still text pills and still clickable, tapping a day still opens the new‑event form, the modal still the 520px centred box, and the date field still 14px β€” proof the anti‑zoom rule stayed inside the media query.
  • A regression sweep over the shipped pages. This change edits two shared files, so the inbox and Assets were measured too. A green Calendar that broke the inbox is not a pass.
  • The one genuine failure the harness caught was the 14px date field β€” which turned out to be #999, a bug in shipped work, not in this work.

⚠️ Two false alarms, both the same cause: measuring mid‑transition. A capture of the week view showed the Month button lit while the week grid was rendered β€” the button's 0.15s transition, caught mid‑flight under virtual time; the DOM was correct. And a colour picker specified at 48px measured 43px via getBoundingClientRect() β€” .modal.active .modal-content animates in with a transform, and a rect is the scaled box, not the laid‑out one. getComputedStyle().height is the honest number for anything inside an animating container. Measure, don't eyeball cuts both ways: it stops you missing bugs, and it stops you inventing them.

⚠️ And one thing measurement alone got wrong β€” the settings table passed every check while being unreadable. That story is above, because it is the more important of the two.

Still owed: a real device pass with Ed, which remains the final word.


Known rough edges / future polish

  • itsm_calendar.js hardcodes English for weekday and month names (DAYS/MONTHS), for 'All day', and for AM/PM times β€” so the month header, the week header and every agenda time read in English in all 24 locales. common.calendar.months and common.calendar.weekdays already exist and are translated, so this is a fix waiting to happen; it is a module bug rather than a mobile one, which is why it wasn't folded into a mobile change.
  • Drag‑to‑move an event works on desktop (HTML5 drag) and does nothing on touch. In scope terms this sits with the other drag surfaces (network mapper, Gantt) β€” deliberately out of scope.
  • The keyboard‑vs‑bottom‑input problem from the tickets rollout applies to the event form and the category modal.
  • The help guide's contents strip is a side‑scroller rather than a proper phone navigation (a sheet or a dropdown would be better). That is a house‑style decision affecting all 26 guides, not a Calendar one.
  • aria-label sweep across the injected chrome is still owed, module‑wide.

Reference

  • CSS: assets/css/mobile.css β€” LAYER 16 (16a–16i), plus the LAYER 3 correction (#999).
  • JS: assets/js/mobile.js β€” initCalendarMobile().
  • Opt‑in wiring: calendar/index.php, calendar/table/index.php, calendar/settings/index.php, calendar/help.php (currently mobile.css?v=33, mobile.js?v=16 β€” on all ten pages that link them).
  • Untouched by design: assets/js/itsm_calendar.js, assets/css/itsm_calendar.css, assets/css/calendar-grid.css β€” the last is shared with the Tickets and Change calendars, so a rule placed in it would reach two modules that haven't been brought along.
  • Changelog: #998 (the module) and #999 (the shared modal‑field fix).
  • Parent: Mobile‑Friendly Β· Siblings: Mobile: Tickets, Mobile: Assets, Mobile: Knowledge, Mobile: Service Status Β· Techniques: Mobile: Techniques & Tricks Β· Module: Calendar.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally