-
Notifications
You must be signed in to change notification settings - Fork 15
Mobile Friendly 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.
| 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".
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.
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: -999pxpushes the title out of the box rather than hiding it, so the title stays in the accessibility tree. -
pointer-events: noneon 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
onclickisopenEventModal(null, dateStr)β so the agenda is wired by wrapping that one function, with no change to the renderer at all.
-
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: 620pxand 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-sidebarnode is relocated into a.mobile-sheet(so#categoryFilterListkeeps its id andrenderCategoryFilters()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
:hoveraffordance 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.
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-tablestraight inside a.tab-content, so the rule never matched. - The page's own CSS gives
.containeranoverflow-y: auto, and per the CSS spec a nonβvisiblevalue on one axis computes the other toautoβ 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 === innerWidthproves 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.cssmust declare its own scroller. LAYER 2 makes<body>a100dvhflex 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 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 |
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.
// 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.
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.
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.
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.
| 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.
</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.
| 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. |
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 loadscalendar/for real β real PHP, real API calls, real cascade. -
Fake events with no database write.
getEventsForDateis 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 === innerWidthat every state, plus every element whose right edge passes the viewport.β οΈ The detector needed three corrections before it was honest: elements inside anoverflow: hiddenancestor, inside a sideβscroller, or under aposition: fixedancestor are contained and cannot widen the viewport β LAYER 2 parks the ticket panes withtransform, and LAYER 9 parks the closed AskβAI panel atright: -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 viagetBoundingClientRect()β.modal.active .modal-contentanimates in with a transform, and a rect is the scaled box, not the laidβout one.getComputedStyle().heightis 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.
-
itsm_calendar.jshardcodes 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.monthsandcommon.calendar.weekdaysalready 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-labelsweep across the injected chrome is still owed, moduleβwide.
- 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(currentlymobile.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 β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)