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

Monday, 14 September 2026

Artificial Intelligence and Its Effect on Entry-level Hiring

Artificial Intelligence, despite not being fully-formed, is no longer merely threatening to shake up the labor market. By most accounts, it has already happened. Even if one takes "AI-washing" (where companies use AI disruption as a convenient scapegoat rather than a genuine reason for layoffs) into account, the fact of the matter is that two classes of tech workers are currently facing extinction.

The first are the leetcoders or hackathoners, who have spent their entire careers training to be the fastest code typers and problem-solvers possible. Entire movies have been made about these rockstars... and now they're finding out that they'll never produce code faster than a machine. I'm trying to find it in my heart to produce some sympathy, but honestly they should have seen this coming. This industry automates so many things; why not coding?

Very screwed.

And if your entire value proposition has been speed, I don't know what to tell ya, son, you're more screwed than a plank at a carpenter's convention.

The second class, of course, are entry-level tech workers.

Entry-level tech workers

The moment LLMs started being capable of churning out subpar code at light speed, at a fraction of the cost of hiring cheap slave labor fresh-faced geeks, the position of these tech workers was in jeopardy. And the LLMs aren't even all that subpar at the time of this writing. I have significantly more sympathy for this demographic than for the leetcoders, due to the simple fact that they never asked for any of this. They didn't make the conscious career mistake of maximizing for typing speed. They learned the fundamentals and did the work, but their careers look dead in the water before even being given a chance to pay their dues.

The bigger concern - one that's already been repeated ad nauseam, but bears repeating nonetheless - is that if entry-level workers aren't even given the opportunity to level up, they aren't going to grow into senior-level workers. And the senior-level workers won't be around forever.

Space to grow.

Entry-level workers should be given the space to make mistakes and grow. Already, "civilians" with LLMs are finding out that being able to build "working software" isn't quite the same thing as a qualified software developer producing something with a baseline level of engineering rigor. For example, I've come across civilian-produced apps that lack Cross-site Request Forgery protection or even expose database ids right in the URL. To provide non-technical readers the proper context, CSRF hasn't been in the OWASP Top Ten for years, but that doesn't mean it's no longer a threat - it just means that enough people have guarded against it that it's no longer a popular successful attack vector. Thus, this represents a step back. Civilians and entry-level workers alike, make mistakes; but unlike civilians, entry-level workers are professionally interested in improving.

Think about it - why is someone like me occupying this niche in the food chain? Because I'm so damn special? There is nothing special about me. I started life as a stupid kid like most of you schmucks reading this, and figured my way out over time. I spent years making mistakes and learning from them, and learning the right questions to ask. I have the scars that neither the entry-level tech worker or the non-tech worker do. More importantly, I was allowed to make those mistakes, in addition to performing the countless (entry-level, often repetitive) tasks that LLMs can now do... but precisely due to having developed that muscle memory, I'm now qualified to validate the LLM's output. If entry-level workers don't have even these tasks, how else will they grow?

Employers will say that it's not their responsibility to train the next generation of developers. Their primary responsibility is to their organizations. That's completely fair.

Who's the bad guy in this story, then?

Well, it's not AI. AI exists, but AI is not the one making the decision not to hire young developers. AI merely makes that decision more of a no-brainer.

It would be easy to paint employers as the bad guys here. Lazy too, and that's why I'm not going to do it.

Sure, they're the ones opting to adopt AI at the expense of entry-level (and even more senior) workers... but it's mostly in response to what their competitors are doing. Some of these employers don't have the luxury of thinking ten years into the future and investing in youth now. They're in the position where they know it's a bad idea, and they have to go ahead anyway. Shit, even some giants in Silicon Valley have to make moves that mirror the competition; what chance do the rest have, really?

The bad guy.

No one is the bad guy. It's just evolution. Insisting there has to be a bad guy is like calling the meteor that deleted all the dinosaurs way back then, the villain.

From where I sit, LLMs are the latest in a long line of evolutions in tech history. Significant? Of course. World-shattering? Whose world, exactly? People like me are on our way out. We don't exactly have much skin in the game.

Crap advice

In these times of uncertainty, the only certainty there's to be found seems to be from the voices of older workers, with a great deal of confidence as to how these new treacherous waters should be navigated. Toughen up. Pivot. Find your spine. Work hard and grow your network.

Excuse the fuck out of me. Toughen up? That's like telling people to learn to swim when a tsunami is crashing onto the beach. Sound advice under most circumstances, but useless for immediate survival.

Learn to swim!

One of the things I really can't stand about older tech workers like myself, or even just older workers my age in general, is that they seem to think that their age makes them intrinsically superior to the younger generation. Don't get me wrong; older and experienced workers come with a good deal of advantages, but it doesn't make us automatically qualified to advise the next generation on how to conduct their careers.

I mean, do you have experience as a young adult trying to find work? Sure. Do you have experience as an older worker trying to find work in 2026? Maybe. Do you know what it's like to be a young adult, trying to find work in 2026? No, pal, you don't fucking know. Obviously, you can't have firsthand experience. And unless you shut your mouth and listen more, you're not likely to have secondhand experience either.

Also, consider this: whatever know-how you may have accumulated over your years of experience has now been commoditized with a correctly-worded prompt. Knowing the right questions to ask is a whole other matter, of course. In fact, that is possibly the only thing separating you from the tech cosplayers with their LLMs pretending that they're the equivalent of fully-trained engineers (some of whom might actually even believe it, poor bastards). But at some point, these young tech workers are going to figure out the right questions to ask. They'll have your know-how, but they have youthful energy and enthusiasm. You have high cholesterol levels and creaking knees. Do the math.

What lies in store? Damn if I know.

I wouldn't want to be a young tech worker in 2026. Not only is the ground shaky, you've got people with the sheer audacity to tell you what you should be doing, in an arena they haven't fought in before. An unenviable position.

I have no answers, not even bad ones. If I did, people would be paying me for my opinion, not reading it for free on this blog.

Have an AI-dealistic day!
T___T

Tuesday, 8 September 2026

With Great Functionality Comes Great Legal Responsibility

It's unclear to me how many of you were actually old enough to use the internet back in the late 90s, but if you were, you might remember this charming little search engine called Ask Jeeves. It had a friendly cartoon butler, Jeeves, ask you what you wanted, and delivered you links, with a witty quip or two. It wasn't extremely powerful, but boy did it have personality!

Good old Jeeves.

Good old Jeeves has been retired for the last couple decades, but Google saw fit to try something in the same vein.

Google's version

It used to be that when you searched for something, Google would act like a normal search engine and give you a series of links to what you searched for. At most, it might suggest that you had mistyped your search, or aggressively promote content that people had paid them money to promote.

Relatively harmless run-of-the-mill stuff, as it were.

Then they started giving us an AI Overview, where an LLM would summarize the results of the search without you needing to visit those sites personally and draw your own conclusions. Which the site owners, understandably, weren't too thrilled at due to the decreased user traffic.

Google summary
on a platter.

At some point, the LLM-generated summary would even appear on top of all other searches (sort of like Jeeves serving you information on his silver platter) giving users very little incentive to do anything other than read that convenient summary. And that's where the trouble began.

In Munich, a regional German court took Google to task over two cases where the AI overview had gone wrong, with disastrous results. Apparently some publishing companies had been erroneously tied to scams and shady business practices due to a hallucination in the LLM. Not something completely unheard of, to be fair.

Google argued that they had explicitly stated that AI overviews could be wrong, they had included the original links, and users were able to click on those links for verification of the AI overview. The court tore that apart with the assertion that very few people ever did (kind of like reading a EULA and then clicking the "I have read the Terms and conditions" checkbox at the end) and that AI Overviews constituted Google's own content which it was legally liable for.

I'll concede that Google's AI Overviews are largely directionally correct, and the odd hallucinations are the exceptions that prove the rule. Unfortunately, due to Google's massive scale and reach, even a 0.1% chance of something going wrong represents a really bad day at the office for an unacceptable number of people.

A case I was reminded of...

Back in the day, Social Media platforms like Facebook and TikTok were being raked over the coals legally for misinformation of users. It's not the exact same case, but there are interesting similarities.

These platforms argued that they were mere content-displaying platforms and could not be held to the same standards as publications. However, the unfortunate fact was that they were implementing algorithms that largely dictated what users saw first. As long as they were doing this, the courts argued, they were effectively publications in their own right.

TikTok as a publication?

That does make sense. After all, don't publications also put their most compelling stories on the front page?

How are these cases similar, though? Well, in the original form of the services, all these companies did was, as claimed, display content provided by other parties. It was the value-added service by the companies themselves, which put them at legal risk. Also worth noting is, in both these cases, the offending mechanism were the default states. Meaning, they weren't configurations that the users could opt into. Therefore, the argument can't be made that "the users chose this", because they quite literally didn't.

Conclusion

User consent factors quite heavily into these lawsuits. It's often not enough to serve users something and then belatedly seek permission to do so. I could be wrong, but I suspect that none of this legal action would have happened if Google had gone with the classic display and only served the AI-enhanced version upon request.

Talk about courting trouble!
T___T