Thursday, 8 October 2026

TeochewThunder: Year Twelve (Part 2/2)

In terms of views, I wouldn't say 2026 was a worse year for this blog. If anything, it performed better than 2025 and on par with 2024, so far anyway.

And as I noted earlier, there were quite a few posts from His Teochewness that showed open contempt for... well, shit, whoever happened to be pissing me off at the time. And because some of these were from way back years ago and I'd had time to properly ruminate over why I got pissed off, the language is arguably a lot less intense but heaps more damaging than if I'd gone in red hot. Because, then, I wasn't wildly throwing haymakers; I was landing kidney shots and throat jabs.

High Performers

These were the ones that got the most attention. And honestly, I'm heartened that they weren't even the most controversial ones. They mostly centered around my home exploration of Singapore. Of course, my Black Mirror Film Reviews have always been disproportionately popular.

Lots of attention.

- A Software Developer's Vacation in... Singapore
- Film Review: Black Mirror Series Seven
- Film Review: Black Mirror Series Seven, Redux
- A Software Developer's Vacation In Singapore: Coast-to-Coast Trail Edition

The one sole standout was Finding Your Purpose Professionally, which involved me dunking on both older and younger workers alike, because they annoy me in equal measure, for different reasons.

Surprise standouts

These are next in line to be mentioned because they got a pretty big audience as well. Some of the attention was expected because I courted it with every F-bomb and verbal strike. (Making the contact tracing effort mandatory, redux and Hiring remote foreigners? Slow your roll, hotshot and The Case against Hunger, Enthusiasm and Other Useless Buzzwords).

This one, Ten Reasons To Keep Treating Job Interviews As Target Practice, in particular, was inflammatory, but I also like to think it was helpful.

The same can't be said of Musk's Macrohard is as silly as it sounds. I didn't intend for it to be helpful. Elon Musk said something stupid (something staggeringly foolish that offended me as a programmer) and I called him out on it. 

Then there were technical pieces like Five Image File Formats For The Web and More ways to declare colors in CSS! and I really couldn't tell you why they turned out as well as the foul-mouthed ones. And then there's Named Versus Positional Notation in Functions and Methods. One of the popular ones. I mean, it's such riveting stuff, right?

Good amounts of attention.

Working has different goals from working out and JavaScript's non-existent relationship to Java and Bolt CEO's dangerous logic for axing his entire HR Team were more philosophical than rude. And yet... boom!

The Case of the Awkward Tech Interviewee was another war story which I didn't think was all that compelling, but I guess reality proved me wrong, there.

Duds

Obviously there were times I was disappointed with the turnout because I really felt my effort deserved more. This could have been down to any number of reasons, chief of which is that my writing just isn't as compelling as I gave it credit for.

For example, Seriously, why try to cook Sam Altman? is a post I thought was great. It had one of my favorite targets in my crosshairs and in it I savaged both Altman and his would-be assassin. No bites. Or just fewer than I imagined I'd get.

Very little attention.

Artificial Intelligence and Its Effect on Entry-level Hiring was another one I put a fair bit of heart into. Perhaps it needs more time to gestate, having just been released last month.

Five Funny Tech CEO Nicknames was another one I thought would be a sure hit. No such luck. It seems a guy can't even rely on making fun of CEOs any more, to get eyeballs.

Onward to Year 13!

This really wasn't a bad blogging year at all. If next year is anywhere as good, I'd consider myself blessed.

I make all these critiques of my blogposts while understanding that I'm really nobody in the big picture and attention isn't something I'm entitled to, even if by some miracle I could handle it.

XII-ding expectations,
T___T

Monday, 5 October 2026

TeochewThunder: Year Twelve (Part 1/2)

It's time for my annual blogging break! Let's look at what 2026 brought us - lots of tech news (mostly about Artificial Intelligence), quite a bit of jQuery and even a bit of NodeJS.

Happy 12th blogiversary!

Twelve years to the day I got off my ass and started this blog. Seems unbelievable. In that time, I've bought my own place, gotten married, quit smoking and learned maybe five different languages - not all of them programming ones.

Those familiar with the TeochewThunder tradition will know that October is the month I take a break and gear up for the end of the year.

Tone

I'll begin the year-end retrospective by conceding that I've been making rather inflammatory posts recently. Specifically towards small biz owners.

The small business owner and I have a complicated relationship. On one hand, I realize that it isn't easy to run a business, and it's far more pleasurable to be working for a small outfit than a huge one with tons of stifling rules. On the other hand, I hate it when small potatoes give themselves airs... and some of these are tiny spuds acting like they're the shit. When in all likelihood, they're just shit.

Tiny spuds.

Suffice to say, if even one of these fellas reads my stuff and ends up going "fuck this guy", I'd pat myself on the back. Really, what are they gonna do? I'm practically retired.

That said, I've managed to keep the profanity somewhat minimal. Not as minimal as last year, obviously, but I'm proud of myself for the effort. That's not to say I can't be provocative and offensive if I don't swear, because I absolutely fucking can. I just don't want to end up pigeonholing myself.

Speaking of Artificial Intelligence...

Not that anyone asked, but all this content has continued to be written by me. None of this was written by AI. That distinction is important. On Social Media, I've noticed a whole slew of long-winded posts that scream "LLM Vomit".

Imperfect as my writing is, it's not that. Sure, I do consult an LLM on content. I run my writing through Claude and ChatGPT and even Gemini. AI checks for typos and unverifiable facts, and cautions me on possible inflammatory speech, and tells me that some people may get their precious feelings hurt. In the final case, I may even smile, and ramp up the offensiveness.

You're offended? I'll
show you offended.

All of that is a far cry from letting an LLM generate text for me while I tweak the wording. You may get a lot of crap from me, but you'll never get that kind of crap.

I don't want to give you that stuff, but the fact of the matter is, I couldn't even if I wanted to. Even after feeding the LLMs my writing and asking them to reproduce it, I've yet to find one that even comes close. That's largely because LLMs try to be as correct and inoffensive as possible. And as longtime readers know, these aren't always top on my list of priorities.

Morever, using an LLM for output generation is just superfluous. I'm not in a position where I need those productivity gains. This blog is not for profit; if I can't even be authentic in this situation, that's just sad.

I don't feel quite the same way about AI image generation, though. Because none of you have ever been under any kind of impression that I took those stock photos myself, and generating images gives me what I want without having to hunt endlessly for it. The only difference is that some photographers won't be receiving my paltry monetary thanks, but it isn't as if they would starve otherwise.

Web Tutorials

As mentioned earlier, there was quite a bit of jQuery this year. That's for good reason.

There's increasingly little value in using vanilla JavaScript to demonstrate stuff. I have to jump through too many hoops and explain too much. And in this day and age, using getElementById() feels positively primitive. Shit, in this day and age, jQuery isn't particularly cutting edge either.

Getting primitive.

jQuery gets me there faster with less fuss, and sometimes that matters more. After years of using vanilla JavaScript, it's what I cut my teeth on and I'll always have a soft spot for it, but at some point pragmatism needs to win out.

There's also some NodeJS and a bit of VueJS. I'm not an ideologue where code is concerned; whatever works best in the given context gets the nod. But above all, I don't want to be a one-trick pony. That's almost tantamount to career suicide in my industry. Not that I have a long fruitful career to look forward to at the age of 49, but it's the principle of the thing.

Other content

The dry technical content (outside of Web Tutorials) is still present in the blog, but it's been drying up a bit. Not to say I don't still enjoy writing those even though nobody seems to enjoy reading them, if the views are any indication, but I realize that AI could do a far better job than me of explaining that stuff.

Still, features like Spot The Bug are still going fairly consistently even though I seem to be doing JavaScript almost exclusively (yes, different frameworks and libraries, but still JavaScript).

One thing I've been doing more of, is lifestyle stuff. Things I've been doing outside of work. It used to be I'd do this a couple times a year by vacationing out of the country, but lately I've found that Singapore can be just as interesting without having to break out my trusty old passport.

Wheels, baby, wheels!

And part of it is just me discovering, after having quit smoking, that I like being allowed in non-smoking areas like parks and nature reserves. Huh. Go figure. Funny the directions life takes us. As it is, I'm discovering a whole new side of Singapore on bicycle. And somehow, part of me just can't stop comparing all this shit to software architecture. It could be that being a software dev has allowed me to appreciate the art of building all this, within the constraints of a tiny island.

Back to Artificial Intelligence

The reason why I've been writing so much about AI is because, love it or hate it, it's hard to simply dismiss it. It's permeating code, Social Media, employment and technology - four subjects this blog tackles regularly.

I can't seem to get by a day on YouTube without listening to some take on AI. How LLMs are good, how they're bad, how the AI Bubble is about to burst...

Code bubble.

Also, I have opinions. Shocking, right?

As a software developer, I also have to deal with colleagues and bosses who range from wildly enthusiastic, to cautiously optimistic, to staunchly negative, and none of these positions is inherently wrong, which makes things a lot more interesting than your typical flat earth debate. The situation changes by the day, and that's genuinely intriguing.

For example, right now there's an ongoing Doomsday discussion as to whether AI will bring about the end of humankind by the end of the decade. By the time I publish my two cents on this, it will be November and my perspective will be stale. But whatevs, it's not like the Earth has stopped spinning on its axis waiting for me to release my opinion to the world, eh?

Next

Hits and misses of Year 12.

Tuesday, 29 September 2026

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

So far we have been working solely with March 2025 data. It's a limited subset of the entire dataset, which is why we could perform paging, sorting and all that, in the front-end. It was a deliberate design decision. This pattern will not scale if you have an unlimited dataset.

With that in mind, we will provide an interface for the user to get the rest of the data. In the topmost div, listFilters, add this HTML. It's a drop-down list.
<div id="listFilters">
  <label>
    Period:
    <select id="ddlPeriod">
      <option value="3">March 2025</option>
      <option value="4">April 2025</option>
    </select>          
  </label>


  <hr />
</div>  


So far, we only have March and April 2025 records, so this is easy - just have the drop-down list run getRecords() with 2025 as the argument for year, and the selected value as the argument for month.
<div id="listFilters">
  <label>
    Period:
    <select id="ddlPeriod" onchange="lv.getRecords(2025, this.value);">
      <option value="3">March 2025</option>
      <option value="4">April 2025</option>
    </select>          
  </label>

  <hr />
</div>  


The CSS for labels is just to bold the text.
  .asc:after
  {
    content: "\25B2";
  }

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

  label
  {
    font-weight: bold;
  }

</style>


Don't need to do much else.


There, these are April borrowing records!


Now for the Search function

Add a space and another label tag, and insert the txtSearch textbox.
<div id="listFilters">
  <label>
    Period:
    <select id="ddlPeriod" onchange="lv.getRecords(2025, this.value);">
      <option value="3">March 2025</option>
      <option value="4">April 2025</option>
    </select>          
  </label>

   &nbsp;

  <label>
    Search:
    <input id="txtSearch" placeholder="Search term, e.g, orange" />
  </label>


  <hr />
</div>  


Have it run setSearch() with its current value, whenever someone types stuff in the box.
<div id="listFilters">
  <label>
    Period:
    <select id="ddlPeriod" onchange="lv.getRecords(2025, this.value);">
      <option value="3">March 2025</option>
      <option value="4">April 2025</option>
    </select>          
  </label>

   

  <label>
    Search:
    <input id="txtSearch" placeholder="Search term, e.g, orange" value="" oninput="lv.setSearch(this.value);" />
  </label>

  <hr />
</div>  


This is the setSearch() method. It has a parameter, searchTerm. We start with a return statement.
setPageSize: function(pageSize)
{
  this.pageSize = pageSize;
  this.pageNo = 1;
  this.filterRecords();
  this.renderPageInfo();

  return;
},
setSearch: function(searchTerm)
{
  return;
},

toggleSort: function(col, id)
{


Now we set the search property. If searchTerm is longer than 2 characters, we convert to lowercase and set it. If not, we set search to an empty string.
setSearch: function(searchTerm)
{
  this.search = (searchTerm.length > 2 ? searchTerm.toLowerCase() : "");

  return;
},


Then we run filterRecords() and renderPageInfo().
setSearch: function(searchTerm)
{
  this.search = (searchTerm.length > 2 ? searchTerm.toLowerCase() : "");
  this.filterRecords();
  this.renderPageInfo();


  return;
},


In filterRecords(), before sorting, we filter according to the search term. We first check if action needs to be taken. If search is less than 2 characters, we won't bother.
filterRecords: function()
{
  $("#listRows").html("");

  var filtered = structuredClone(this.records);

  if (this.search.length > 2)
  {

  }


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


We then filter the filtered array using the filter() method.
if (this.search.length > 2)
{
  filtered = filtered.filter
  (
    (x) =>
    {

    }
  );

}


We check against the title, category, author and name properties, all converted to lowercase, against the search property, and return true if there is any match.
if (this.search.length > 2)
{
  filtered = filtered.filter
  (
    (x) =>
    {
      return (
        x.title.toLowerCase().indexOf(this.search) > -1 ||
        x.category.toLowerCase().indexOf(this.search) > -1 ||
        x.author.toLowerCase().indexOf(this.search) > -1 ||
        x.name.toLowerCase().indexOf(this.search) > -1

      );
    }
  );
}


Let's also remove the red outlines since we hve no more layout changes to make.
div {outline: 0px solid red;}


Let's try typing in "orange" like the nice placeholder text recommends. Start with "or". See how nothing happens.


Now add "a" for "ora". Now see the results change dramatically. They display because "ora" was in the title, or because the author was "Dora" "Deborah", "Flora" or "Coral".

Try the full term "orange". Now there's only one result!


Now switch the records to April 2025. Can you see the logic behind this?

We're done here! Thanks for participating!

Play around with your list view. Try out the features we made.

Orange you glad we did this?!
T___T

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.