Sunday, 27 September 2026

Web Tutorial: The List View (Part 3/4)

Welcome back. Let's deal with sorting now, by adding to the sort array.

The default sorting for the list view will be first alphabetically by title, then by most recently borrowed. Each sorting detail is an object of its own. The sequence matters.
const lv =
{
  pageSize: 50,
  pageNo: 1,
  sort: [ {"col": "title", "dir": "asc"}, {"col": "borrowdate", "dir": "desc"} ],
  search: "",
  records: [],


Now, in filterRecords(), we want action only if there's stuff in sort. Therefore, we insert a conditional block here. Then we iterate through sort using a forEach() loop.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  if (this.sort.length > 0)
  {
    this.sort.forEach(
      (x) =>
      {

      }
    );
  }


  var startIndex = ((this.pageNo - 1) * this.pageSize);


This next If block tests if "title", "author", "name" or "category" are the columns being sorted here - they're text data, so the comparison will be string-based.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  if (this.sort.length > 0)
  {
    this.sort.forEach(
      (x) =>
      {
        if (["title", "author", "name", "category"].indexOf(x.col) > -1)
        {
                
        }

      }
    );
  }

  var startIndex = ((this.pageNo - 1) * this.pageSize);


We check for dir. If the value is "asc" or "desc", we compare accordingly using the localeCompare() method.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  if (this.sort.length > 0)
  {
    this.sort.forEach(
      (x) =>
      {
        if (["title", "author", "name", "category"].indexOf(x.col) > -1)
        {
          if (x.dir == "asc") filtered.sort((a, b) => a[x.col].localeCompare(b[x.col]));
          if (x.dir == "desc") filtered.sort((a, b) => b[x.col].localeCompare(a[x.col]));    
              
        }
      }
    );
  }

  var startIndex = ((this.pageNo - 1) * this.pageSize);


For "borrowdate", "returndate" or "returnby", these are date comparisons. We convert the strings to Date objects and compare.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  if (this.sort.length > 0)
  {
    this.sort.forEach(
      (x) =>
      {
        if (["title", "author", "name", "category"].indexOf(x.col) > -1)
        {
          if (x.dir == "asc") filtered.sort((a, b) => a[x.col].localeCompare(b[x.col]));
          if (x.dir == "desc") filtered.sort((a, b) => b[x.col].localeCompare(a[x.col]));                  
        }

        if (["borrowdate", "returndate", "returnby"].indexOf(x.col) > -1)
        {
          if (x.dir == "asc") filtered.sort((a, b) => new Date(a[x.col]) - new Date(b[x.col]));
          if (x.dir == "desc") filtered.sort((a, b) => new Date(b[x.col]) - new Date(a[x.col]));        
        }

      }
    );
  }

  var startIndex = ((this.pageNo - 1) * this.pageSize);


"fine" is currently the only numerical column sortable, and that one's the most straightforward.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  if (this.sort.length > 0)
  {
    this.sort.forEach(
      (x) =>
      {
        if (["title", "author", "name", "category"].indexOf(x.col) > -1)
        {
          if (x.dir == "asc") filtered.sort((a, b) => a[x.col].localeCompare(b[x.col]));
          if (x.dir == "desc") filtered.sort((a, b) => b[x.col].localeCompare(a[x.col]));                  
        }

        if (["borrowdate", "returndate", "returnby"].indexOf(x.col) > -1)
        {
          if (x.dir == "asc") filtered.sort((a, b) => new Date(a[x.col]) - new Date(b[x.col]));
          if (x.dir == "desc") filtered.sort((a, b) => new Date(b[x.col]) - new Date(a[x.col]));        
        }

        if (["fine"].indexOf(x.col) > -1)
        {
          if (x.dir == "asc") filtered.sort((a, b) => a[x.col] - b[x.col]);
          if (x.dir == "desc") filtered.sort((a, b) => b[x.col] - a[x.col]);        
        }

      }
    );
  }

  var startIndex = ((this.pageNo - 1) * this.pageSize);


Now take a close look - the records are ordered by the column "Borrowed", most recent dates first. Then take a look at those records borrowed on "2025-03-23". Note that the titles are sorted alphabetically in that group!


Cool! Next, you'll want to properly display what's being sorted, and have a mechanism to change the sorting. In the CSS, allow sortButton to turn red if hovered over as well.
  .sortButton, .noButton
  {
    width: auto;
    padding: 0px;
    height: 1.5em;
    font-size: 1em;
    background: transparent;
    border: none;
    color: rgb(0, 0, 0);
  }

  .pageButton:hover, .sortButton:hover
  {
    color: rgb(255, 0, 0);
  }
</style>


Then we use the after pseudoselector on sortButton. We want it to be a tiny square that has some spacing from the end of the text. By default, there is no text content.
  .pageButton:hover, .sortButton:hover
  {
    color: rgb(255, 0, 0);
  }

  .sortButton:after
  {
    width: 10px;
    height: 10px;
    margin-left: 5px;
    display: inline-block;
    content: "";
  }

</style>


But then we declare asc and desc as new CSS classes with pseudoselectors. In these cases, asc uses a upwards pointing triangle as content and desc uses a triangle pointing down.
  .pageButton:hover, .sortButton:hover
  {
    color: rgb(255, 0, 0);
  }

  .sortButton:after
  {
    width: 10px;
    height: 10px;
    margin-left: 5px;
    display: inline-block;
    content: "";
  }

  .asc:after
  {
    content: "\25B2";
  }

  .desc:after
  {
    content: "\25BC";
  }

</style>


Add asc to btnSortTitle and desc to btnSortBorrowdate.
<div id="listHeader" class="listViewColumns">
  <div><button class="noButton">No</button></div>
  <div><button id="btnSortTitle" class="sortButton asc">Title</button></div>
  <div><button id="btnSortAuthor" class="sortButton">Author</button></div>
  <div><button id="btnSortCategory" class="sortButton">Category</button></div>
  <div><button id="btnSortName" class="sortButton">Name</button></div>
  <div><button id="btnSortBorrowdate" class="sortButton desc">Borrowed</button></div>
  <div><button id="btnSortReturnBy" class="sortButton">Return By</button></div>
  <div><button id="btnSortReturndate" class="sortButton">Returned</button></div>
  <div><button id="btnSortFine" class="sortButton">Fine</button></div>
</div>


Look at the headings. Here's your visual indicator!


Make sure each heading (other than the "No" column) runs the toggleSort() method when clicked, with the actual column name and button id passed in as arguments.
<div id="listHeader" class="listViewColumns">
  <div><button class="noButton">No</button></div>
  <div><button id="btnSortTitle" class="sortButton asc" onclick="lv.toggleSort('title', this.id)">Title</button></div>
  <div><button id="btnSortAuthor" class="sortButton" onclick="lv.toggleSort('author', this.id)">Author</button></div>
  <div><button id="btnSortCategory" class="sortButton" onclick="lv.toggleSort('category', this.id)">Category</button></div>
  <div><button id="btnSortName" class="sortButton" onclick="lv.toggleSort('name', this.id)">Name</button></div>
  <div><button id="btnSortBorrowdate" class="sortButton desc" onclick="lv.toggleSort('borrowdate', this.id)">Borrowed</button></div>
  <div><button id="btnSortReturnBy" class="sortButton" onclick="lv.toggleSort('returnby', this.id)">Return By</button></div>
  <div><button id="btnSortReturndate" class="sortButton" onclick="lv.toggleSort('returndate', this.id)">Returned</button></div>
  <div><button id="btnSortFine" class="sortButton" onclick="lv.toggleSort('fine', this.id)">Fine</button></div>
</div>


Create toggleSort() with a return statement.
setPageSize: function(pageSize)
{
  this.pageSize = pageSize;
  this.pageNo = 1;
  this.filterRecords();
  this.renderPageInfo();

  return;
},
toggleSort: function(col, id)
{
  return;
},      
  
filterRecords: function()
{


We declare currentIndex and set it to -1. Then we clear the column's button of both asc and desc CSS classes, returning it to a visually neutral state.
toggleSort: function(col, id)
{
  var currentIndex = -1;
  $("#" + id)
  .removeClass("asc")
  .removeClass("desc");


  return;
},  


Then we traverse the sort array. If we find a match for the column name, we set currentIndex to the value of i, which is the matching element. This means that the current column being clicked on, is in the sort array.
toggleSort: function(col, id)
{
  var currentIndex = -1;
  $("#" + id)
  .removeClass("asc")
  .removeClass("desc");

  this.sort.forEach(
    (x, i) =>
    {
      if (x.col == col)
      {
        currentIndex = i;
      }
    }
  );


  return;
},  


Now, we have an If block to check if the current column name is represented in the sort array. If currentIndex is still -1 at this point, then it's not.
toggleSort: function(col, id)
{
  var currentIndex = -1;
  $("#" + id)
  .removeClass("asc")
  .removeClass("desc");

  this.sort.forEach(
    (x, i) =>
    {
      if (x.col == col)
      {
        currentIndex = i;
      }
    }
  );

  if (currentIndex > -1)
  {

  }
  else
  {

  }

  
  return;
},  


In that case, we'll push a new object into sort, with the current column's name and a default dir property value of "asc". We'll amend the current button's class accordingly as well.
toggleSort: function(col, id)
{
  var currentIndex = -1;
  $("#" + id)
  .removeClass("asc")
  .removeClass("desc");

  this.sort.forEach(
    (x, i) =>
    {
      if (x.col == col)
      {
        currentIndex = i;
      }
    }
  );

  if (currentIndex > -1)
  {

  }
  else
  {
    this.sort.push({ "col": col, "dir": "asc"});
    $("#" + id).addClass("asc");

  }
  
  return;
},  


Now, if the current column is already in sort, we'll want to change its direction. We first check if the current dir property value is "asc".
toggleSort: function(col, id)
{
  var currentIndex = -1;
  $("#" + id)
  .removeClass("asc")
  .removeClass("desc");

  this.sort.forEach(
    (x, i) =>
    {
      if (x.col == col)
      {
        currentIndex = i;
      }
    }
  );

  if (currentIndex > -1)
  {
    if (this.sort[currentIndex].dir == "asc")
    {

    }
    else
    {

    }

  }
  else
  {
    this.sort.push({ "col": col, "dir": "asc"});
    $("#" + id).addClass("asc");
  }
  
  return;
},  


If so, we change it to "desc" and amend the CSS class accordingly. If it's not "asc", then it can only be "desc". And if it's already "desc", we remove the entire object from the sort array accordingly, using the splice() method with currentIndex passed in. And finally, at the end of all that, we run filterRecords().
toggleSort: function(col, id)
{
  var currentIndex = -1;
  $("#" + id)
  .removeClass("asc")
  .removeClass("desc");

  this.sort.forEach(
    (x, i) =>
    {
      if (x.col == col)
      {
        currentIndex = i;
      }
    }
  );

  if (currentIndex > -1)
  {
    if (this.sort[currentIndex].dir == "asc")
    {
      this.sort[currentIndex].dir = "desc";
      $("#" + id).addClass("desc");

    }
    else
    {
      this.sort.splice(currentIndex, 1);
    }
  }
  else
  {
    this.sort.push({ "col": col, "dir": "asc"});
    $("#" + id).addClass("asc");
  }
  
  this.filterRecords();
  return;
},  


Refresh. Reduce page size to 20 for better clarity. Click on the column "Borrowed". The down-pointing triangle should disappear, and now the dataset is sorted only by "Title".


Click on "Title". Now it sorts descending.


Click on "Category". Now you see it that first sorts by "Title", then it sorts by "Category".


Click on "Title" twice, till the triangle disappears. Now it's just sorted by "Category". Click on "Title" again, so now it's sorted by "Category" first, then "Title". What a difference!


Have fun clicking on the other headers. You can sort by as many columns as you want.

Next

Searching, and getting different years' records.

Thursday, 24 September 2026

Web Tutorial: The List View (Part 2/4)

Currently, there are about 150 records being viewed. Technically, it's not a problem, but what if we have double that number? Ten times?

Visually, any number of records that requires the user to scroll, is potentially a problem. Thus, we need a mechanism in which the user can limit the page size to a number that they're comfortable with. pageSize has already been set at a default of 50 earlier; we will keep that default and add the mechanism to change it.

For this, we need some HTML inside the div. It's a select tag, with the options 20, 50, 100 and 500. 50 is selected by default.
<div id="listPaging">
  <hr />

  <div>
    Records per Page
    <select>
      <option value="20">20</option>
      <option value="50" selected>50</option>
      <option value="100">100</option>
      <option value="500">500</option>
    </select>  
  </div>

</div>


For what it's worth, this is how the selector looks right now. Note that we still show 149 records even though page size is 50.


On changing the value in this drop-down list, we run the setPageSize() method and pass in the value as an argument.
<div id="listPaging">
  <hr />

  <div>
    Records per Page
    <select onchange="lv.setPageSize(this.value)">
      <option value="20">20</option>
      <option value="50" selected>50</option>
      <option value="100">100</option>
      <option value="500">500</option>
    </select>  
  </div>
</div>


We'll create setPageSize(). In there, we start by setting pageSize to the argument passed in, and pageNo to 1. We end, of course, with a return statement.
const lv =
{
  pageSize: 50,
  pageNo: 1,
  sort: [],
  search: "",
  records: [],
  getRecords: function(year = 2025, month = 3)
  {
    $.ajax(
      {
        url: `https://oracleapex.com/ords/teochewthunder/borrowing/${year}/${month}`,
        method: "GET",
        success: function(data)
        {
          lv.records = data.items;
          lv.filterRecords();

          return;
        },
        error: function (xhr, status, error)
        {
          console.log(xhr);
        }
      }
    );        
  },
  setPageSize: function(pageSize)
  {
    this.pageSize = pageSize;
    this.pageNo = 1;

    return;
  },     
 
  filterRecords: function()
  {


And after that's done, we run filterRecords() to display the records with the new value of pageSize.
setPageSize: function(pageSize)
{
  this.pageSize = pageSize;
  this.pageNo = 1;
  this.filterRecords();

  return;
},  


Now it's time to tweak filterRecords() to honor pageSize. Before iterating through filtered, we first reduce the size of filtered. We begin by declaring startIndex. The page we're on will determine what record we start from - the first record of the current page. If the current page is 1, of course we'll start from the very first record at index 0. If the current page is 2 and page size is 20, we'll start at record 20. Because page 1 is records 0 to 19.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  var startIndex = ((this.pageNo - 1) * this.pageSize);

  filtered.forEach(
    (x, i) =>
    {
      const row = $("<div></div>");
      row.addClass("listViewColumns");
      if (i % 2 == 0) row.addClass("shade");


And then filtered will have the slice() method run on it. We'll only want to view pageSize records. Thus, we slice from startIndex to startIndex plus pageSize.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  var startIndex = ((this.pageNo - 1) * this.pageSize);

  filtered = filtered.slice(startIndex, startIndex + parseInt(this.pageSize));

  filtered.forEach(
    (x, i) =>
    {
      const row = $("<div></div>");
      row.addClass("listViewColumns");
      if (i % 2 == 0) row.addClass("shade");


Now you'll see that only 50 records are shown, because default page size is 50!


If you adjust it to 20, the view shrinks to 20 records!


Time to control pageNo!

Add this div, and these button and span tags inside that div. These buttons - styled using the pageButton CSS class - are for first, last, previous and next page, not in that order. In between, we have three span tags which are placeholders for the values later. Take note of the ids - I think they're aptly named and very self-explanatory.
<div id="listPaging">
  <hr />

  <div>
    <button id="btnFirst" class="pageButton">«</button>
    <button id="btnPrev" class="pageButton">‹</button>  
  
    <span id="pnlPageNo"></span>
    <span>/</span>
    <span id="pnlPages"></span>

    <button id="btnNext" class="pageButton">›</button>
    <button id="btnLast" class="pageButton">»</button>
  </div>


  <div>
    Records per Page
    <select onchange="lv.setPageSize(this.value)">
      <option value="20" >20</option>
      <option value="50" selected>50</option>
      <option value="100">100</option>
      <option value="500">500</option>
    </select>  
  </div>
</div>


As for the CSS, here's the styling for pageButton. All of it visual. I've even ensured that it turns red on mouseover.
  .figure
  {
    text-align: right;
  }

  .pageButton
  {
    width: 20px;
    height: 20px;
    font-size: 1.2em;
    background: transparent;
    border: none;
    color: rgb(0, 0, 0);
  }


  .sortButton, .noButton
  {
    width: auto;
    padding: 0px;
    height: 1.5em;
    font-size: 1em;
    background: transparent;
    border: none;
    color: rgb(0, 0, 0);
  }

  .pageButton:hover
    color: rgb(255, 0, 0);
  }

</style>


You can see the new arrows now.


Just a bit of a nudge. Add a couple spaces after the drop-down list...
<div id="listPaging">
  <hr />

  <div>
    <button id="btnFirst" class="pageButton">«</button>
    <button id="btnPrev" class="pageButton">‹</button>  
  
    <span id="pnlPageNo"></span>
    <span>/</span>
    <span id="pnlPages"></span>

    <button id="btnNext" class="pageButton">›</button>
    <button id="btnLast" class="pageButton">»</button>
  </div>

  <div>
    Records per Page
    <select onchange="lv.setPageSize(this.value)">
      <option value="20" >20</option>
      <option value="50" selected>50</option>
      <option value="100">100</option>
      <option value="500">500</option>
    </select>  

    &nbsp; &nbsp;   
  </div>
</div>


...and float them right.
#listPaging
{
  width: 100%;
}

#listPaging div
{
  float: right;
}


.figure
{
  text-align: right;
}


Looks neater now.


What we will do next, is assign click actions to methods. There are two methods here. setPageNo() is an atomic method that can be used on its own. It accepts one argument, which is the page number to set. setPage() is a bit more abstract. The argument is a string that is a relative value - "last", "prev" or "next". I did not bother with "first" because that's always page 1.
<div>
  <button id="btnFirst" class="pageButton" onclick="lv.setPageNo(1)">«</button>
  <button id="btnPrev" class="pageButton" onclick="lv.setPage('prev')">‹</button>  

  <span id="pnlPageNo"></span>
  <span>/</span>
  <span id="pnlPages"></span>

  <button id="btnNext" class="pageButton" onclick="lv.setPage('next')">›</button>
  <button id="btnLast" class="pageButton" onclick="lv.setPage('last')">»</button>
</div>


Now let's create these methods with return statements.
getRecords: function(year = 2025, month = 3)
{
  $.ajax(
    {
      url: `https://oracleapex.com/ords/teochewthunder/borrowing/${year}/${month}`,
      method: "GET",
      success: function(data)
      {
        lv.records = data.items;
        lv.filterRecords();

        return;
      },
      error: function (xhr, status, error)
      {
        console.log(xhr);
      }
    }
  );        
},
setPage: function(index)
{
  return;
},
setPageNo: function(pageNo)
{
  return;
},

setPageSize: function(pageSize)
{
  this.pageSize = pageSize;
  this.pageNo = 1;
  this.filterRecords();

  return;
},


Let's work on setPageNo() first. There's going to be a new variable, finalPageNo. This is the value of pageNo, the parameter accepted in the method, after it's been through a few checks. For this, we next define pages, which is the value returned by calling getPages.
setPageNo: function(pageNo)
{
  let finalPageNo = pageNo;
  let pages = this.getPages();


  return;
},


Create getPages(). This one is simple. It takes the total number of records and divides it by the number of records per page, which gives us the total number of pages. We want to round up this number, so using the ceil() method of Math is appropriate here.
getRecords: function(year = 2025, month = 3)
{
  $.ajax(
    {
      url: `https://oracleapex.com/ords/teochewthunder/borrowing/${year}/${month}`,
      method: "GET",
      success: function(data)
      {
        lv.records = data.items;
        lv.filterRecords();

        return;
      },
      error: function (xhr, status, error)
      {
        console.log(xhr);
      }
    }
  );        
},
getPages: function()
{
  return (Math.ceil(this.records.length / this.pageSize));
},

setPage: function(index)
{
  return;
},
setPageNo: function(pageNo)
{
  let finalPageNo = pageNo;
  let pages = this.getPages();

  return;
},


Now that you have pages, you check if pageNo is less than 1, and ensure that the minimum value is 1. Similarly, you ensure that the maximum value is pages.
setPageNo: function(pageNo)
{
  let finalPageNo = pageNo;
  let pages = this.getPages();
  if (finalPageNo < 1) finalPageNo = 1;
  if (finalPageNo > pages) finalPageNo = pages;


  return;
},


Then you set pageNo to finalPageNo, run filterRecords(), and run renderPageInfo().
setPageNo: function(pageNo)
{
  let finalPageNo = pageNo;
  let pages = this.getPages();
  if (finalPageNo < 1) finalPageNo = 1;
  if (finalPageNo > pages) finalPageNo = pages;

  this.pageNo = finalPageNo;
  this.filterRecords();
  this.renderPageInfo();


  return;
},


Hang on, what's renderPageInfo()?

Well, that's the method we will create next. It renders the display of the number of pages and current page. It will be run in a few places, not just setPageNo().
  prettifyDate: function(dt)
  {
    var arr = dt.split("T");
    return arr[0];
  },
  renderPageInfo: function()
  {
    return;
  }

};


It's not too complex. Just populate pnlPageNo with the value of pageNo and pnlPages with the total number of pages. Note that we just call getPages() here.
  prettifyDate: function(dt)
  {
    var arr = dt.split("T");
    return arr[0];
  },
  renderPageInfo: function()
  {
    $("#pnlPageNo").text(this.pageNo);
    $("#pnlPages").text(this.getPages());


    return;
  }
};


Now for setPage()! You'll see that it has a parameter, index. That's the relative value I mentioned earlier. We have a few If blocks here, questioning the value of index.
setPage: function(index)
{
  if (index == "next")
  if (index == "prev")
  if (index == "last")


  return;
},


This is fairly straightforward at this point. If you want the next page, run setPageNo() with an incremented value of pageNo, and let setPageNo() do its thing. Similarly for the previous page. As for the last page, we'll run getPages() here again.
setPage: function(index)
{
  if (index == "next") this.setPageNo(this.pageNo + 1);
  if (index == "prev") this.setPageNo(this.pageNo - 1);
  if (index == "last") this.setPageNo(this.getPages());

  return;
},


We'll need to run renderPageInfo() in getRecords() too.
getRecords: function(year = 2025, month = 3)
{
  $.ajax(
    {
      url: `https://oracleapex.com/ords/teochewthunder/borrowing/${year}/${month}`,
      method: "GET",
      success: function(data)
      {
        lv.records = data.items;
        lv.filterRecords();
        lv.renderPageInfo();

        return;
      },
      error: function (xhr, status, error)
      {
        console.log(xhr);
      }
    }
  );        
},


And setPageSize().
setPageSize: function(pageSize)
{
  this.pageSize = pageSize;
  this.pageNo = 1;
  this.filterRecords();
  this.renderPageInfo();

  return;
},


And before I forget, in filterRecords(), we'll need to cater for new page numbers, so the record numbering will also depend on what page is being viewed.
col = $("<div></div>");
col.text(i + 1 + startIndex);
col.addClass("figure");
row.append(col);


Try it!
On the first load, default page Size is 50. See? You're viewing page 1 of 3, because there are 149 records.


Change page size to 20. Now you have 8 pages!


Click the next page. You're now viewing record 21 to 40!


Click the last page. Now you're viewing records 141 to 149!


This was a little long. Thanks for bearing with me. The next part will be just as complicated, but hopefully shorter!

Next

Sorting records.

Tuesday, 22 September 2026

Web Tutorial: The List View (Part 1/4)

List views are a classic way to present data. I used to work with them in PHP, back in the 2010s. As my experience accumulated, I got a better sense of what worked, what didn't, what scaled, and what sounded like a good idea and just wasn't in practice.

With the help of jQuery, I'd like to walk you through some of the regular features of a list view that I worked over and over with.

To facilitate this Web Tutorial, I have also created a database of sample records in Oracle APEX and created an API endpoint to retrieve those records. It's a collection of records from a hypothetical library, with imaginary titles, authors and people. The categories, though, are real - they're from BISAC.

Here's what a sample of the data looks like in CSV. At least, this was what I imported into Oracle APEX. In this Web Tutorial, we wont be dealing with the API endpoint creation at all - we'll just call the endpoint and grab the data.
Title,Category,Author,Name,BorrowDate,ReturnBy,ReturnDate,Fine
Help my program just crashed,Computers,RJ Neville,Olive Yao,2025-03-01,2025-04-01,2025-03-22,0
The Saraville Murders,True Crime,Lillian Yang,Faith Wentz,2025-03-01,2025-05-01,2025-04-21,0
The Moon's Reflection in the Padi Fields,Fiction,Rudy Ismail,Charles Wong,2025-03-01,2025-04-01,2025-03-30,0
It's Now or Never!,Young Adult Fiction,Kara Leow,Lin Yiheng,2025-03-01,2025-04-01,2025-04-02,0.1
Dreaded Doberman: Man's Best Friend... or are they?,Pets,Selina Perez,Teo Teng Hwee,2025-03-01,202

...


Here's a sample API endpoint for these records. In this URL, "2026" and "3" are the year and month that the books were borrowed. Do note that we're not doing security for this - it's a purely display feature.
https://oracleapex.com/ords/teochewthunder/borrowing/2026/3


Here's some boilerplate HTML. We want to include jQuery, and the starting object, lv. In the styles, the outline of divs has been set to red. This will help us with layout until we're ready to turn it off.
<!DOCTYPE html>
<html>
  <head>
    <title>List View</title>

    <style>
      div {outline: 1px solid red;}
    </style>

    <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>

    <script>
      const lv =
      {

      };
    </script>
  </head>

  <body>

  </body>
</html>


In the body tag, we have a div tag with the id listContent. Within it, here are four different divs with the ids listFilters, listHeader, listRows and listPaging. The first and last div tag have a hr tag within. You may not think much about it now, but that horizontal rule will be a Godsend later.
<body>
  <div id="listContent">
    <div id="listFilters">
      <hr />
    </div>  
        
    <div id="listHeader">

    </div>

    <div id="listRows">
      
    </div>

    <div id="listPaging">
      <hr />
    </div>
  </div>

</body>


listContent is a thousand pixels wide, with the height property set to auto. We center it using the margin property, and set a font. The other divs within will just inherit their parent's width.
<style>
  div {outline: 1px solid red;}

  #listContent
  {
    width: 1000px;
    height: auto;
    margin: 0 auto 0 auto;
    font-size: 12px;
    font-family: verdana;
  }

  #listHeader
  {
    width: 100%
  }

  #listRows
  {
    width: 100%
  }

  #listPaging
  {
    width: 100%;
  }

</style>


Now for every column we'll have, we add a div...
<div id="listHeader">
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>

</div>


...and insert a button with that column's name in it. Note that there is one more button at the beginning - that is just a column header for serialization of records. Note that each of these buttons, save the first one, has an unique id.
<div id="listHeader">
  <div><button>No</button></div>
  <div><button id="btnSortTitle">Title</button></div>
  <div><button id="btnSortAuthor">Author</button></div>
  <div><button id="btnSortCategory">Category</button></div>
  <div><button id="btnSortName">Name</button></div>
  <div><button id="btnSortBorrowdate">Borrowed</button></div>
  <div><button id="btnSortReturnBy">Return By</button></div>
  <div><button id="btnSortReturndate">Returned</button></div>
  <div><button id="btnSortFine">Fine</button></div>
</div>


Now you see a bunch of buttons. These are supposed to be clickable headers.


So let's style them accordingly. Each of these buttons is styled using the sortButton CSS class. Only the first button uses noButton.
<div id="listHeader">
  <div><button class="noButton">No</button></div>
  <div><button id="btnSortTitle" class="sortButton">Title</button></div>
  <div><button id="btnSortAuthor" class="sortButton">Author</button></div>
  <div><button id="btnSortCategory" class="sortButton">Category</button></div>
  <div><button id="btnSortName" class="sortButton">Name</button></div>
  <div><button id="btnSortBorrowdate" class="sortButton">Borrowed</button></div>
  <div><button id="btnSortReturnBy" class="sortButton">Return By</button></div>
  <div><button id="btnSortReturndate" class="sortButton">Returned</button></div>
  <div><button id="btnSortFine" class="sortButton">Fine</button></div>
</div>


Here's the styling. Visually, sortButton and noButton share the same visuals. No background or border - it just looks like black text wth a fixed height.
#listPaging
{
  width: 100%;
}

.sortButton, .noButton
{
  width: auto;
  padding: 0px;
  height: 1.5em;
  font-size: 1em;
  background: transparent;
  border: none;
  color: rgb(0, 0, 0);
}


Now they no longer look like buttons.


Time for a bit of layout magic. Add the CSS class listViewColumns to the listHeader div.
<div id="listHeader" class="listViewColumns">
  <div><button class="noButton">No</button></div>
  <div><button id="btnSortTitle" class="sortButton">Title</button></div>
  <div><button id="btnSortAuthor" class="sortButton">Author</button></div>
  <div><button id="btnSortCategory" class="sortButton">Category</button></div>
  <div><button id="btnSortName" class="sortButton">Name</button></div>
  <div><button id="btnSortBorrowdate" class="sortButton">Borrowed</button></div>
  <div><button id="btnSortReturnBy" class="sortButton">Return By</button></div>
  <div><button id="btnSortReturndate" class="sortButton">Returned</button></div>
  <div><button id="btnSortFine" class="sortButton">Fine</button></div>
</div>


In the CSS, we set the display property to grid, and then specify the widths of 8 columns, with a 5 pixel gap between columns.
#listRows
{
  width: 100%
}

.listViewColumns
{
  display: grid;
  grid-template-columns: 1fr 10fr 6fr 6fr 6fr 5fr 5fr 5fr 3fr;
  gap: 5px;
}


#listPaging
{
  width: 100%;
}


There, it's in one line now.


Time for JavaScript!

Now we're going to populate those rows. We'll first need to flesh out the lv object. We first have some properties.
- pageSize: How many records are shown on a page at any time. We'll set it at 50 as a default, but this won't affect anything right now.
- pageNo: The current page being shown. The default is 1. The value may change as pages are gone through.
- sort: An array which we'll use to carry out sorting later. Leave it empty for now.
- search: A string that will be used for searching values. Leave empty for now.
- records: An array of the current records being worked on. Leave it empty for now.
<script>
  const lv =
  {
    pageSize: 50,
    pageNo: 1,
    sort: [],
    search: "",
    records: []

  };
</script>


The next two are methods which will be relevant immediately. getRecords() will populate the records array. By default, it will fetch records in March 2025, so its two parameters, year and month, are set accordingly. filterRecords() will display the contents of records.
<script>
  const lv =
  {
    pageSize: 50,
    pageNo: 1,
    sort: [],
    search: "",
    records: [],
    getRecords: function(year = 2025, month = 3)
    {

    },
    filterRecords: function()
    {

    }

  };
</script>


In getRecords(), we have a jQuery AJAX call.
getRecords: function(year = 2025, month = 3)
{
  $.ajax(
    {

    }
  );  
      
},


In it, we specify the URL and that it will be a GET operation. The URL is basically a template string that incorporates year and month.
getRecords: function(year = 2025, month = 3)
{
  $.ajax(
    {
      url: `https://oracleapex.com/ords/teochewthunder/borrowing/${year}/${month}`,
      method: "GET",
      success: function(data)
      {

      },
      error: function (xhr, status, error)
      {
        console.log(xhr);
      }

    }
  );        
},


On success, we set records to the items array of data, and then run the filterRecords() method.
getRecords: function(year = 2025, month = 3)
{
  $.ajax(
    {
      url: `https://oracleapex.com/ords/teochewthunder/borrowing/${year}/${month}`,
      method: "GET",
      success: function(data)
      {
        lv.records = data.items;
        lv.filterRecords();

        return;

      },
      error: function (xhr, status, error)
      {
        console.log(xhr);
      }
    }
  );        
},


We begin the method with a return statement at the end. At the start, we clear the contents of the listRows div. Then we declare filtered. It's an independent copy of records, using the structuredClone() function.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  return;

},


Then we iterate through filtered using a forEach() loop. x is the element and i is the index. If you're wondering why the array's named "filtered", that's because later on we'll be putting it through filtering.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  filtered.forEach(
    (x, i) =>
    {
        
    }
  );


  return;
},


Here, we create a div element, row. It will be styled using the CSS class listViewColumns, which means it will be a single-line grid just like the header! Then we append row to listRows.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  filtered.forEach(
    (x, i) =>
    {
      const row = $("<div></div>");
      row.addClass("listViewColumns");

      $("#listRows").append(row);      
      
    }
  );

  return;
},


But before that, we declare col. Repeatedly, we define col as a div element, use the text() method to insert that element's data, and append it to row. We begin with the serial number, which is just 1 added to i so we don't have the 0 value at the start. The 0 would make sense to programmers. To everyone else, not so much.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  filtered.forEach(
    (x, i) =>
    {
      const row = $("<div></div>");
      row.addClass("listViewColumns");

      let col;
      col = $("<div></div>");
      col.text(i + 1);
      row.append(col);
      col = $("<div></div>");
      col.text(x.title);
      row.append(col);
      col = $("<div></div>");
      col.text(x.author);
      row.append(col);
      col = $("<div></div>");
      col.text(x.category);
      row.append(col);
      col = $("<div></div>");
      col.text(x.name);
      row.append(col);
      col = $("<div></div>");
      col.text(x.borrowdate);
      row.append(col);
      col = $("<div></div>");
      col.text(x.returnby);
      row.append(col);
      col = $("<div></div>");
      col.text(x.returndate);
      row.append(col);
      col = $("<div></div>");
      col.text(x.fine);
      row.append(col);

      $("#listRows").append(row);            
    }
  );

  return;
},


Note that for all numerical and date data, we'll also style col using the figure CSS class.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  filtered.forEach(
    (x, i) =>
    {
      const row = $("<div></div>");
      row.addClass("listViewColumns");

      let col;
      col = $("<div></div>");
      col.text(i + 1);
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(x.title);
      row.append(col);
      col = $("<div></div>");
      col.text(x.author);
      row.append(col);
      col = $("<div></div>");
      col.text(x.category);
      row.append(col);
      col = $("<div></div>");
      col.text(x.name);
      row.append(col);
      col = $("<div></div>");
      col.text(x.borrowdate);
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(x.returnby);
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(x.returndate);
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(x.fine);
      col.addClass("figure");
      row.append(col);
      $("#listRows").append(row);            
    }
  );

  return;
},


figure is just setting the text to align right.
#listPaging
{
  width: 100%;
}

.figure
{
  text-align: right;
}


.sortButton, .noButton
{
  width: auto;
  padding: 0px;
  height: 1.5em;
  font-size: 1em;
  background: transparent;
  border: none;
  color: rgb(0, 0, 0);
}


After the lv object, set getRecords() to run as soon as the document loads.
          col.text(x.fine);
          col.addClass("figure");
          row.append(col);
          $("#listRows").append(row);            
        }
      );

      return;
    }
  };

  $(document).ready(
    function()
    {
      lv.getRecords();
    }
  );

</script>


You see here, we have the records! But the dates look weird...


No matter. Let's run all the date values through the prettifyDate() method.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  filtered.forEach(
    (x, i) =>
    {
      const row = $("<div></div>");
      row.addClass("listViewColumns");

      let col;
      col = $("<div></div>");
      col.text(i + 1);
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(x.title);
      row.append(col);
      col = $("<div></div>");
      col.text(x.author);
      row.append(col);
      col = $("<div></div>");
      col.text(x.category);
      row.append(col);
      col = $("<div></div>");
      col.text(x.name);
      row.append(col);
      col = $("<div></div>");
      col.text(this.prettifyDate(x.borrowdate));
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(this.prettifyDate(x.returnby));
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(this.prettifyDate(x.returndate));
      col.addClass("figure");
      row.append(col);
      col = $("<div></div>");
      col.text(x.fine);
      col.addClass("figure");
      row.append(col);
      $("#listRows").append(row);            
    }
  );

  return;
},


Create this method. It basically just cleans up the value by using the split() method to divide up the string by the "T" character, and then returning only the first element of the resultant array.
          col.text(x.fine);
          col.addClass("figure");
          row.append(col);
          $("#listRows").append(row);            
        }
      );

      return;
    },
    prettifyDate: function(dt)
    {
      var arr = dt.split("T");
      return arr[0];
    }

  };
</script>


Now it's looking much better.


It's a small thing, but let's get this out of the way. Create CSS class shade. Its background color is a very translucent black.
.listViewColumns
{
  display: grid;
  grid-template-columns: 1fr 10fr 6fr 6fr 6fr 5fr 5fr 5fr 3fr;
  gap: 5px;
}

.shade
{
  background-color: rgba(0, 0, 0, 0.1);
}


#listPaging
{
  width: 100%;
}


Then in filterRecords(), when styling row using the listViewColumns CSS class, we check to see if index i is even, using the modulus operator. If so, also style row using shade.
const row = $("<div></div>");
row.addClass("listViewColumns");
if (i % 2 == 0) row.addClass("shade");

let col;
col = $("<div></div>");
col.text(i + 1);
col.addClass("figure");
row.append(col);



More presentable and readable now.

That's about it for now. We'll do paging through records next.

Next

Implementing paging.

Saturday, 19 September 2026

Five Funny Tech CEO Nicknames

Insults. Perjoratives. Pet names. Nicknames. I love these, especially the really witty ones. Today let's go through some of my favorite nicknames for tech CEOs. These are notable not just because they're (arguably) good puns, but because they actually describe perception of that person. Whether or not I personally agree (and make no mistake, there are instances where I'm enthusiastically on board), there's no running away from the fact that these are pretty awesome nicknames.

Also, these images were generated by Meta AI, because... well, what else is it good for, really?

1. Scam Altman

This is the CEO of OpenAI. And why, one might ask, is this fine fellow called "Scam"? "Sham" works just as well, but I have a certain fondness for the former because it was coined by no other than Elon Musk, who, quite fittingly, is the next entry after this one.

Slimy AF.

Fairly or otherwise, Altman is widely associated with AI hype and overpromising. You say "Snake Oil Salesman" and immediately I see this guy's face in my mind. 

To be fair, tech isn't just tech. A commercial angle has to exist in order to justify its existence. So having a "salesy" tech CEO isn't necessarily such an outrageous thing. It's more Altman's constant overpromising of what OpenAI can deliver, that irks people. I mean, how many times do people have to hear from this guy's mouth that AGI is coming in a few months?

2. Phony Stark

Not an instant classic unless you're also a Marvel fan. But if you are, you'd be familiar with Elon Musk's predilection for styling himself after a certain fictional genius-billionaire-playboy-philanthropist.

SpaceX-hole.

Too bad all he's got is the money. The requisite charm and wit to be an actual Tony Stark is squarely in the minds of his fevered fanboys.

And let's not act like Musk didn't do his darnedest to earn that nickname. His constant cringy posturing on X, that insufferable grin and the superfluous attention-seeking antics (because if you're already the richest person on this planet, why do you need that attention?) completely nail the performative tech CEO stereotype.

Hey, if the shoe fits...

3. Zuckerborg

This made me spit-take, because both the target and the reason for the name, are instantly recognizable.

Cringy robot.

Mark Zuckerberg, CEO of Meta, has long been famous for looking robotic. Which is probably unfair because he talks pretty naturally.

It all started during the Cambridge Analytica debacle, where he showed up looking less lively than a wax replica. It's almost no exaggeration to say that an entire generation was brought up on memes of Zuckerberg looking like a, well, Zuckerborg.

4. Jeff Beelzebos

Jeff Bezos, owner of tech giant Amazon, is conflated with the Devil for good reason. Which makes this one pretty clever.

Modern-day slave-owner.

Bezos is infamous for Amazon's widely-criticized treatment of their warehouse workers, said to border on the inhumane. Timed bathroom breaks, unreasonable work quotas, low wages and unsafe working environments - these issues have been mostly discussed and dissected by the general public.

Come on now, if this is what you're known for in addition to being insanely rich, why wouldn't people associate you with Beelzebub?

5. Tim Crook

I'll be honest - I have no idea why former Apple CEO Tim Cook is seen as so unethical as to warrant this nickname. I looked it up. Apparently, violating antitrust conventions and using child cheap labor in China. Basically, typical big-company CEO shit.

Bad guy?

I get why these would make him unpopular. Just not sure this makes him a crook. Did this guy actually break any laws? Maybe I'm the problem and I should stop interchanging the terms "crook" and "criminal".

But hey, if we're just looking for good witty nicknames, this one surely qualifies.

What's in a nickname?

There were some other options which I left out precisely because they're so obvious. I mean - calling Jeff Bezos, "Bozos". Or Mark Zuckerberg, "Zuckface". Really?! That's not witty, that's just juvenile. There's a difference.

Sticks and stones may break my bones...
T___T