Planning with Gantt & Calendar #JoelKallmanDay

Overview

Building an app to solve a personal need is a great learning opportunity. Sometimes I plan multi-day trips and must decide among overlapping events. The annual animation festival in Annecy, France, is one example. Hundreds of talks and screenings span various venues in a lovely little lakeside locale. Another is a periodic pilgrimage to New York City to see as many musicals and museums as I can fit in a week. An APEX app helps me plan my trip activities. This year I celebrate Joel Kallman Day by sharing my app and explaining the smorgasbord of APEX tricks I learned by building it.

(Technical Note: to see a larger version of any figure, right click on it and choose Open Image in New Tab from the context menu…)

Recurring Trips with Overlapping Events

Both the Annecy Festival and my New York City trips happen periodically during a particular year over a series of days, so a trip name and year represent each edition of a trip (e.g. New York City, 2023). Each edition has a start and end day and consists of events to attend that may repeat multiple times during the week. These different event runs might be in distinct venues, like a movie that shows in different cinemas around Annecy. In contrast, all the event runs might be in the same Broadway theatre. In the case of the Annecy Festival, films can be from a particular country, in an original language, and have subtitles in another language, so as shown below my data model also includes countries and languages.

Data model for Activity Planner application

Gantt Chart Showing Per-Day Show Runs

To easily see the overlapping events on each day of the trip, I use a day selector and Gantt chart as shown in the figure below. The query for the Gantt chart region includes columns DAY_START and DAY_END whose values come from combining the selected day with user preferences for day start time and day end time. The query also includes STARTS_AT and ENDS_AT datetime values for each event run that falls on the selected day. It concatenates the name of the event and the name of the venue into a NAME column that’s used as the task name. To suppress the display of task name along the left edge of each row, I selected the Attributes tab of the chart region, and set Show Row Axis to OFF.

Gantt Chart showing many overlapping event runs on Wednesday, September 20th

Day Selector’s Data-Driven Date Range

The EVENT_DAYS Shared Components List of Values (LOV) driving the day selector was an interesting query to write. In the EBA_DEMO_ANN_EDITIONS table for a trip edition we have a START_DATE and END_DATE for the consecutive sequence of days over which the trip edition takes place. I needed a query that returned all of the dates in order starting with the start date and ending with the end date for the current trip edition. The application item CURRENT_EDITION holds its ID value. The query ended up looking like this:

SELECT
  to_char(FIRST_DAY + LEVEL - 1,'fmDy DD Mon') AS DAY_NAME_IN_PERIOD,
  LEVEL AS DAY_NUMBER
FROM (SELECT FIRST_DAY, LAST_DAY
        FROM EBA_DEMO_ANN_EDITIONS
       WHERE ID = :CURRENT_EDITION)
CONNECT BY
  FIRST_DAY + LEVEL - 1 <= LAST_DAY
ORDER BY
  DAY_NUMBER ASC

This query drives the day selector below with an ordered list of formatted day names between start date and end date for the current trip edition:

Day selector for the current trip edition on the Schedule page

Stretching Chart to Fill the Viewport

I could have hard-coded the height of the Gantt chart region on the Attributes tab of the property editor, but that didn’t meet my needs. In the weeks leading up to a trip, I use the app on different desktop computers with differently-sized monitors as well as on my iPad, so I explored how the Gantt chart could stretch to use whatever vertical space is available.

After many failed experiments, I landed on the following technique. It uses the CSS calc() function to compute the value of the height property. First, I defined a CSS class on the page called vertical-stretch and assigned this class to the Gantt chart region. Then I defined the class in the page-level CSS > Inline settings using the syntax:

.vertical-stretch {
   height: calc(100vh - 17rem);
}

The calculation references the full (100%) viewport height by using 100vh and subtracts from that the height of the rest of the elements on the page above the Gantt chart. Using CSS rem units that represent the height (i.e. font-size) of the root element on the page, through trial and error I discovered 17rem closely matched the space used by the other content on the page. Applying the vertical-stretch CSS class to the Gantt chart region means, “Your height is the viewport space remaining after accounting for the vertical space occupied by the elements above the Gantt chart”. This approach made the app look exactly how I wanted on every monitor size and iPad where I use it.

When resizing the window interactively, the chart also reacts as expected as shown in this short video:

Editing Events by Clicking on a Bar

The Event page (7) in the app lets the user create, edit, or delete an event and all its related event runs. When looking at a particular day’s events in the Gantt chart on the Schedule page (14), I wanted clicking on any bar to open the event related to that particular event run. So I set out to find how to obtain the event run ID related to each bar in the Gantt chart. I started by configuring the Gantt chart’s Task Id property to get its value from the event run ID column in the region’s query.

Next, I hoped to find the value of this task ID somewhere in the HTML markup in the page. By right-clicking on a Gantt bar rectangle and choosing Inspect Element, the browser developer tools showed me that each bar was a Scalable Vector Graphics (SVG) group element <g> containing two <path> elements and a <text> element like this:

<!-- Example SVG group of shapes for Task Id 162 "bar" -->
<g id="_dvtActiveElement43100180_162">
  <path d="..." class="oj-gantt-task-backdrop"></path>
  <path d="..." class="oj-gantt-task oj-gantt-task-bar"></path>
  <text         class="oj-gantt-task-label" ... >
    Local Fauna Preview (Philippe Labaune Gallery)
  </text>
</g>

The <path> element containing the class="oj-gantt-task" looked like the right element to target for a click handler. So I defined a dynamic action to react to the Click event on the CSS class oj-gantt-task using the jQuery Selector with a dot before the class name as shown below:

Configuring a dynamic action on the Click event of the element with CSS class oj-gantt-task

I also noticed the Gantt Chart Task Id value was part of the value of the id attribute on the <path> element’s containing parent <g> element. For example, for an event run with ID = 162, the id attribute of the <g> parent element for the clicked-on bar had a value like “_dvtActiveElement43100180_162“. I learned from my colleagues that the dynamic action event handler can access the triggering <path> element using the expression this.triggeringElement. So, my dynamic action event handler passes the triggering element to an eventRunIdForClickedGanttBar() helper function in my page. It returns the Task Id value by parsing it out of the parent element’s id attribute like this:

function eventRunIdForClickedGanttBar(p_this) {
  return extractNumberFromUnderscoreString($(p_this).parent()[0].id);
}

The calling dynamic action event handler looks like this. It sets the value of a hidden page item P14_SELECTED_EVENT_RUN_ID to hold the event run ID that was clicked on, sets the Request to EDIT_EVENT_RUN, and submits the page.

const lTaskId = eventRunIdForClickedGanttBar(this.triggeringElement);
// Set Task id to hidden page item, and submit
// the page with EDIT_TASK request
apex.page.submit({
    request: "EDIT_EVENT_RUN",
    set: {
        "P14_SELECTED_EVENT_RUN_ID": lTaskId
    }
});

In the page’s Processing tab, an After Processing branch redirects to the Event page (7) when the Request = EDIT_EVENT_RUN, passing along this hidden page item value for the target page’s P7_EVENT_RUN_ID page item value. It also passes its own page number 14 as the value of the P7_CALLING_PAGE_ID page item. In contrast, when the Home page (1) calls the Event page to edit an event, its link passes in the value 1 for this page item.

Passing the event run ID for the clicked-on Gantt bar to the Event page

The Event page uses the P7_CALLING_PAGE_ID item value to conditionally return to the right calling page when the user cancels or applies their changes in the Event page. The target Event page has a Pre-Rendering page process Get Event from Event Run Id that looks up the parent event ID to edit based on the “child” row’s event run ID passed in.

Saving Time During Event Data Entry

Each event has a duration in minutes, and each event run has a start time and end time. Using two dynamic actions on the Event page shown below, I speed up data entry by computing the duration if it’s null when a pair of Starts At and Ends At times are entered for an event run. In addition, once the duration is set, any other event runs entered benefit from another dynamic action that automatically computes the Ends At time once the user enters a Starts At and tabs out. This feature makes it very quick to enter all the dates and times a particular musical like “& Juliet” is offered during a trip edition.

Event page editing the Broadway musical & Juliet and its event runs

The page contains the following two helper functions in its JavaScript Function and Global Variable Declaration section the the property editor:

function addMinutesToTime(time, minutes) {
  return apex.date.format(
           apex.date.add(
             new Date("2023-01-01 "+time),
             parseInt(minutes),
             apex.date.UNIT.MINUTE),
           "HH24:MI");
}

function minutesBetween(time1, time2) {
  var date1 = apex.date.parse(time1, "HH24:MI");
  var date2 = apex.date.parse(time2, "HH24:MI");
  var diff = Math.abs(date2.getTime() - date1.getTime());
  return Math.floor((diff / 1000) / 60);
}

Then, the dynamic action on the interactive grid’s ENDS_AT column has a Set Value action step that executes JavaScript to call minutesBetween() to compute the duration of the event:

minutesBetween($v('STARTS_AT'), $v('ENDS_AT'))

It only runs if the duration is currently null and both the STARTS_AT and ENDS_AT columns in the current row have a value using the client-side condition boolean expression:

$v('STARTS_AT')       !== '' && 
$v('ENDS_AT')         !== '' &&
$v('P7_DURATION_MIN') === ''

Similarly, the dynamic action on the grid’s STARTS_AT column has a a Set Value action step that executes JavaScript to call addMinutesToTime() to compute the ENDS_AT time, based on the duration of the event:

addMinutesToTime($v('STARTS_AT'), 
                 $v('P7_DURATION_MIN'))

It only runs if the duration is not null, the STARTS_AT has a value, and the ENDS_AT is null using the client-side condition boolean expression:

$v('STARTS_AT')       !== '' && 
$v('P7_DURATION_MIN') !== '' &&
$v('ENDS_AT')         === '' 

Marking Event Runs as Starred/Ticketed

When reviewing a large list of events, there’s a good chance that you’re not interested in all of them. The app lets the user “star” a particular event run to mark it as a “favorite”. This results in setting the STARRED column value to “Y“. In addition, some trips involve a booking or ticketing process to confirm a seat for a particular event run. The app lets the user indicate they’ve confirmed their place by setting a Tickets switch to ON. In the figure below, I’m starring both Wednesday, September 20th performances of musical “& Juliet” and marking the 14:00 showing as ticketed.

Marking a particular event run as both Starred and Ticketed

Conditionally Coloring Selected Bars

It can be useful to conditionally color the Gantt chart bars based on application-specific criteria. In my app, I want “starred” events to show with one color that makes them distinct, while “ticketed” events show with another unique color. This was easy to accomplish. I first included the following SELECT list expression in the query for the Gantt chart region in the Schedule page (14):

case 
   when r.tickets           = 'Y  then 'tickets'
   when r.event_starred     = 'Y' 
     or r.event_run_starred = 'Y' then 'starred'
end as css_classes

Then I set the Task CSS Classes property of the Gantt chart to &CSS_CLASSES. The final step was adding the following simple CSS class definitions to the page’s CSS > Inline section in the property editor:

.tickets {
   fill: blue;
}

.starred {
   fill: yellow;
}

After the user applies changes on the Event page, it conditionally branches back to the calling page. Since the Schedule page passed its page number 14 into the P7_CALLING_PAGE_ID page item, the user returns to the Schedule page. As shown below, we immediately see conditionally colored event run bars reflected in the Gantt chart. As dictated by the above CSS classes, the bar for the ticketed event run is now blue while the bar for the starred one is now yellow.

Gantt bar for starred showing appears in yellow, while ticketed showing is blue

Narrowing Focus: Hiding Clashing Events

When you have identified your starred event runs, it can be useful to narrow your focus to only see your favorites. This can help you decide which particular event runs to book or buy tickets for. The Schedule page has a switch at the top labeled Hide Starred Session Clashes. When this is switched on, as shown below the Gantt chart suppresses all other event runs that would overlap with any of your favorites. Very useful, since you can’t be in two places at once!

Hiding event runs that clash with your favorites to focus your attention

This feature is implemented directly in the SQL query for the Gantt chart region using an appropriate WHERE clause predicate. It uses the switch page item as a bind variable and uses a NOT EXISTS(…) clause to include any unstarred events that do not have clashes with any starred ones.

and (:p14_hide_starred_session_clashes = 'N' 
     or
     (:p14_hide_starred_session_clashes = 'Y' and
       ((r.event_starred='Y' or r.event_run_starred='Y')
         or
         (r.event_starred='N' 
          and not exists (
            select 1
              from eba_demo_ann_event_runs_v starred_run
             where (
               starred_run.event_starred='Y' or
               starred_run.event_run_starred='Y')
               and ((
                 /* starts before and ends after current */
                 starred_run.starts_at < r.starts_at and
                 starred_run.ends_at   > r.ends_at)
                 or (
                 /* starts after and ends before current */
                 starred_run.starts_at > r.starts_at and
                 starred_run.starts_at < r.ends_at)
                 or (
                 /* ends after current starts & before current ends */
                 r.starts_at         <= starred_run.ends_at and
                 starred_run.ends_at <= r.ends_at)
                 or (
                 /* starts after current & before current ends */
                 r.starts_at           <= starred_run.starts_at and
                 starred_run.starts_at <= r.ends_at)))))))

Focusing Further: After Getting Tickets

After you booked your seat or bought your ticket for one particular showing, you no longer need to see all of the other available opportunities to see the same event. The Schedule page has a second switch at the top labeled Hide Already Ticketed Events. As shown below, the Tuesday 19th September showing of “& Juliet” does not appear since above we already marked the Wednesday 2pm showing as being ticketed.

Hiding other showings of an event you have already bought tickets for

Again, this feature is implemented with a different SQL WHERE clause predicate using this other switch page item as a bind variable. When the variable is set to “Y” it uses an event_id not in (…) with a subselect to exclude event runs for ticketed events from days other than the currently selected day (:p14_day).

and (nvl(:p14_hide_ticketed_from_other_days,'N') = 'N' 
     or (
       nvl(:p14_hide_ticketed_from_other_days,'N') = 'Y' 
       and 
       event_id not in (
         select distinct event_id
           from eba_demo_ann_event_runs_v
          where tickets='Y' 
            and event_day !=  :p14_day)
       )
     )

Since the Gantt chart query references three different page items as bind variables, we have to ensure that its Page Items to Submit property mentions all their names so that their values are sent to the server whenever the region refreshes its query results. The figure below shows the three page item names in a comma-separated list.

Always ensure Page Items to Submit includes names of any page items used as bind variables

Geocoded Date in the Venues Page

As shown below, the Venues page uses the techniques explained in my article Refitting Filtered Map Points to provide a filterable list of venues with an automatically-synchronized map that zooms and recenters to show the locations of the filtered venue list.

Filtering the list of venues to see the narrowed results on a map

The companion Venue page for creating and editing venues uses a Geocoded Map page item to simplify finding the longitude and latitude for a given address. In order to have the Geocoded Map page item show the appropriate pin on the map when editing an existing venue, we use the following expression in the region query to create an appropriate SDO_GEOMETRY value based on the v.longitude and v.latitude values stored in the table. Importantly, the Query Only property of the corresponding P11_GEOCODED_ADDRESS page item is set to ON so that the APEX engine doesn’t try to update any SDO_GEOMETRY column in the underlying EBA_DEMO_ANN_VENUES table that has LATITUDE and LONGITUDE instead.

/* 
 * if latitude/longitude have values, then
 * return sdo_geometry structure representing
 * the point (longitude,latitude)
 */
case 
  when v.longitude is not null 
   and v.latitude is not null then
     sdo_geometry(2001,
                  4326,
                  sdo_point_type(
                    v.longitude,
                    v.latitude,
                    null),
                  null,
                  null)
        end geocoded_address

Since we’re not asking APEX to save the SDO_GEOMETRY value directly to an underlying SDO_GEOMETRY column, when the user enters a new address and triggers the geocoding, we need to pick out the longitude and latitude so they get stored correctly in the respective, underlying columns of the same name. The P11_GEOCODED_ADDRESS page item has a dynamic action named When Address Confirmed that reacts to the Result Selection [Geocoded Address] event to trigger two Set Value dynamic action steps to set the P11_LONGITUDE and P11_LATITUDE page items with the values the geocoding service has returned. They reference the this.data.longitude and this.data.latitude JavaScript expressions to get the job done.

Geocoded Address page item lets APEX resolve longitude and latitude automatically

APEX’s geocoding service depends on knowing the country code of the address the user is typing in. The Venue page has a Pre-Rendering page process in the Before Header section named Default Trip Info for Current Edition. It contains the following PL/SQL block that looks up the trip ID and country code based on the value of the current edition the user is working with. Note that it only assigns the P11_TRIP_ID if the primary key P11_CODE is null (meaning that we’re creating a new venue):

for j in (select e.trip_id,t.country_code 
            from eba_demo_ann_editions e 
            left join eba_demo_ann_trips t on t.id = e.trip_id
           where e.id = :CURRENT_EDITION) loop
    if :P11_CODE is null then
        :P11_TRIP_ID := j.trip_id;
    end if;
    :P11_CURRENT_TRIP_COUNTRY_CODE := j.country_code;
end loop;

Then, the Geocoded Address page item is configured to use the P11_CURRENT_TRIP_COUNTRY_CODE page item for geocoding country information. This ensures that when defining a venue for my Annecy trip it uses FR for France as the geocoding country and when creating one for my New York City trip, it uses US for the United States.

Configuring geocoded address country

Let’s suppose we defined another trip for “Annecy Festival” with the country of France configured, then defined a new edition of this trip for the year 2024, and then finally set this new “Annecy Festival 2024” as the current trip event to work with using the Settings page explained in the next section. Then as shown below, the geocoding of venue addresses would be done with France as the country.

Venue address geocoding uses current trip edition country code

Settings Page

The Settings page (8) lets the user change four user preferences: the current trip edition to view/edit, whether to show countries and languages information, and the start time and end time to use in the Schedule Gantt chart page. The page shown below uses conditional Before Header computations to set the values of the page’s items based on first trying to use user preferences, then conditionally falling-back to using the same-named application items. The application item names (which match the corresponding user preferences) are CURRENT_EDITION, SHOW_COUNTRIES_AND_LANGUAGES, DAY_START_TIME, and DAY_END_TIME. I admit that I wasn’t super excited about the way I had to capture the Day Start Time and Day End Time using four separate select lists, but this was the user experience I had in mind for the task. Maybe a future APEX release will offer a time picker page item for this purpose.

Setting four different user preferences that affect the Activity Planner app

To save the Settings page item changes back to the user preferences, the page has one page process in the Processing section for each user preference that each use the built-in User Preferences page process type to save the updated value to the corresponding same-named user preference. Another four Execute Code page processes in the same section set the preference values into the corresponding application items, since those are what get referenced in various SQL queries around the app.

Focusing Calendar on a Date Range

Once you’ve marked one or more event runs as ticketed, get a bird’s eye view of your trip on the Calendar page. This page focus the users attention on the current trip edition by ensuring the right start day is shown immediately. It sets the calendar to use the START_DATE of the current edition to be the current date displayed, and to start the week display with this date, too.

Calendar gives bird’s eye view of ticketed event runs during the current trip edition

Assuming the calendar region has a static ID of calendar, setting its initial display date requires just one line of JavaScript in the Execute when Page Loads section of the page properties that calls the gotoDate() function on the calendar widget:

apex.region("calendar")
    .widget()
    .data('fullCalendar')
    .gotoDate($v('P12_START_DATE'))

With similar ease, after first computing the day of the week the START_DATE represents, we can set the first day of the calendar week in the calendar region’s Initialization JavaScript Function. The calendar expects its firstDay property to be a day number: 0=Sunday, 1=Monday, etc. I use a Pre-Rendering computation in the Before Header section to compute the value of a hidden P12_ZERO_BASED_FIRST_DAY using the following SQL query:

select to_number(to_char(first_day,'D'))-1
from eba_demo_ann_editions
where id = :CURRENT_EDITION

Setting the calendar’s firstDay property is essentially a one-line task. The code for the Initialization JavaScript Function on the Attributes tab of the property editor for the calendar region appears below. It assigns the value of the zero-based first day to the firstDay property of the pOptions object passed in.

function ( pOptions ) {
    pOptions.firstDay = $v('P12_ZERO_BASED_FIRST_DAY');                                    
    return pOptions;
}

Augmenting App Title with Page Name

It’s a personal preference of course, but as you can see in the previous screenshots, I like the “Top” style Navigation Menu layout since its options stay visible. I combine this style with the use of the After Logo position that lets me place additional context like the page name in the application navigation bar. This leaves more room for content on each page. For example, on the Calendar page I added a Page Title Static Content region with the following HTML Code:

<span class="apex-logo-text"> -&nbsp; Calendar</span>

This uses the same font as the logo text, and adds a hyphen separator and the name of the current page. As shown below, I set its Template to None and its Position to After Logo. This combo gives me a result that feels clean and spacious, and I’ve done the same thing on every non-modal page of the app.

Adding custom content like the page name to the After Logo position in the navigation bar

Grouping Event Runs by Event

The application’s Home page (1) has an Interactive Report showing a searchable list of all event runs for the current trip edition. To group the list of event runs by the event they belong to, I ran the page and used the Actions > Format > Control Break menu to add the Event break column as shown below.

Adding control break group to an Interactive Report to group event runs by event name

Next, to ensure the event runs sort in the most logical sequence for the end user, I used the Actions > Data > Sort menu to configure the report to first sort by Event then sort by Event Day Number as shown here.

Defining the sort order to show event runs in a logical day order

Finally, I saved the current state of the Interactive Report as the new default primary report for all users. As shown below, I used the Actions > Report > Save Report menu, choosing As Default Report Settings in the Save Report dialog, then ensured the Default Report Type was set to Primary in the Save Default Report dialog before clicking Apply.

Updating the primary default report to use current settings of the Interactive Report

Of course, the option to update the primary default report does not appear for end users. I was able to access it since I ran this page as a developer from the APEX Builder.

Formatting a URL as a Hyperlink

My colleague Jayson Hanes helped me figure out how to display an info icon on the Event page to open the event URL in a new browser tab. This should have been a simple task, but the first several approaches I tried didn’t work like I wanted. Luckily, Jayson gave me this helpful tip. The trick was to add a P7_URL_LINK page item of type Display Only to my page, set its Icon property to the fa-info-circle-o, then use the Advanced > Pre Text setting to add an enclosing, open tag for the hyperlink. It uses the substitution string &P7_URL!ATTR. in the value of the <a> tag’s href attribute. The special suffix !ATTR makes sure the value is escaped correctly to appear in an HTML attribute. I added a title attribute so the hyperlink would gain a “tooltip” when the end-user hovers over the info icon with their mouse.

<a href="&P7_URL!ATTR." 
   target="_blank" 
   title="Click to open this page in a new tab">

Then I used the corresponding Post Text setting on the page item to add the closing </a> tag. This produced exactly the desired effect I wanted as shown below. Thanks, Jayson!

Giving the end user an easy way to open the Event URL in a new browser tab

PWA: Instant Desktop/Tablet Use

I took most of the screenshots of the Activity Planner app in this article while running it as a Progressive Web App (PWA). This lets me launch the app directly from the dock of my Mac laptop whenever I need to use it. Combined with my enabling the Persistent Authentication feature at the APEX instance level, this produces a great user experience. I launch the planner app like any other app on my desktop or iPad and instantly continue my trip planning work.

Enabling an APEX app for PWA use takes just two clicks on the Shared Components > Progressive Web Page page in the APEX Builder. Just switch the Enable Progressive Web App switch to ON, as well as the Installable switch just below it. Enabling Persistent Authentication is an APEX instance administrator task in the Manager Instance > Security page.

Saving Quick SQL in Supporting Objects

The idea for this final tip was hatched after realizing every script in the Supporting Objects area of an APEX app could be made conditional. The feature uses the same options as a Server-side Condition elsewhere in the Builder. It offers a powerful way to control which scripts should run when. A simple corollary is that I can easily store the Quick SQL text for my application data model as a script with Condition = Never. This makes it easy to reference and maintain the QuickSQL as requirements change and enhancements get implemented, but ensures that APEX never tries to execute it.

Consistent Switch Value Labels in Grids

While doing final edits of this article I noticed that the Starred and Tickets switch columns in the Event page’s interactive grid would show values Yes and No for unmodified event run rows, but changed to show On and Off for rows I had edited. I resolved that by explicitly setting the On Label and Off Label properties for the Switch component settings in my app under Shared Components > Component Settings> Switch as shown below.

Explicitly setting the On and Off labels for Switch components

Planning Your Next Trip?

Just in case you might find my Activity Planner app useful, or even just educational to experiment with, you can download the app here. Its Supporting Objects scripts install the event run data for my most recent New York City trip, with none of the event runs starred or ticketed yet so you can experiment with those aspects yourself. Enjoy!

Broadway musical “& Juliet” cast members take their bows