Friday, 17 July 2026

Web Tutorial: NodeJS Chinese Name Transliterator

The Romanization of Chinese names have always been a subject of fascination for me. I've had Vietnamese colleagues and Chinese colleagues from Hong Kong and Taiwan. I've watched Korean dramas that were dubbed in Mandarin, and wondered what the original Korean names were.

Today, I want to present this tool I wrote in NodeJS - it accepts a single Chinese name, in Chinese characters, and provides transliterations of the name in various languages.

Setup

After installing my project using the node command, I installed Express and Handlebars.
npm install --save express
npm install --save express-handlebars


This is what exists in app.js, as a baseline. We load Express and Handlebars, and ensure that the view engine is Handlebars. We also ensure that main is the default layout, and we'll create that soon. We also declare assets as the folder where static content is served, and set the default page to serve the view for home. And then set the 404 and 500 pages. Lastly, we start the app up using the listen() method.

app.js
var express = require("express");

var app = express();

var handlebars = require("express-handlebars").create({defaultLayout:"main"});
app.engine("handlebars", handlebars.engine);

app.set("view engine", "handlebars");
app.set("port", process.env.PORT || 3000);

app.use(express.static("assets"));

app.get("/", (req, res)=> {
  res.render("home");
});

app.use((req, res, next)=> {
  res.status(404);
  res.render("404");
});

app.use((err, req, res, next)=> {
  res.status(500);
  res.render("500", { errorMessage: err.code });
});

app.listen(app.get("port"), ()=> {

});


This is the main layout file. Note the links to CSS and JavaScript. In the body, we have a div with id pnlContainer, which will contain the content of whatever page the app serves.

views/layouts/main.handlebars
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Chinese Name Transliterator</title>

    <link rel="stylesheet" type="text/css" href="css/styles.css">
    <script type="text/javascript" src="js/functions.js"></script>
  </head>
  <body>
    <div id="pnlContainer">
      {{{ body }}}    
    </div>
  </body>
</html>


Let's just create a very basic CSS file that only specifies font. We can add more later.

asset/css/styles.css
body
{
    font-size: 16px;
    font-family: georgia;
}


In the JavaScript, we'll just have one single function, to check if a string is made up of only Chinese characters. To do that, we use a Regular Expression of a range of Chinese characters, then use the test() method with name as the argument, returning the result. That's also incidentally the only JavaScript validation we'll need in here.

asset/js/functions.js
function isValidChineseName(name) {
    const re = /^[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF]+$/;
    return re.test(name);
}


Here's the view for 404s.

views/404.handlebars
<h1>404</h1>

<p>Not found!</p>


And for error 500s.

views/500.handlebars
<h1>500</h1>

<p>There was an error.</p>
<p><b>{{ errorMessage }}</b></p>


And here's the default page. You'll notice there is no form tag; we won't need one because I don't actually intend to do any form submission here. What we do have is a nice header, a div (id pnlSelection), a button (id btnTransliterate) and a span tag (id pnlError) for showing errors. And a script tag for more shenanigans later.

views/home.handlebars
<h1>Chinese Name Transliterator</h1>

<div id="pnlSelection">

</div>

<button id="btnTransliterate">TRANSLITERATE</button>
<span id="pnlError"></span>

<script>

</script>


In pnlSelection, we have two more divs. One contains a label, and the other contains a textbox. The id of the textbox is txtFullChineseName and it will have at most 4 characters, unless you feel like transliterating full Manchurian names or something.

views/home.handlebars
<h1>Chinese Name Transliterator</h1>

<div id="pnlSelection">
  <div><label for="txtFullChineseName">Full Chinese Name</label></div>
  <div><input id="txtFullChineseName" maxlength="4" /></div>

</div>

<button id="btnTransliterate">TRANSLITERATE</button>
<span id="pnlError"></span>

<script>

</script>


This probably looks a mess. Time for more CSS.


pnlContainer fits the full height of the window (thus 100vh), has a maximum width of 400 pixels, which will fit nicely on mobile, and is centered in the middle of the window via the margin property. Text is centered by default.

asset/css/styles.css
body
{
    font-size: 16px;
    font-family: georgia;
}

#pnlContainer
{
    min-height: 100vh;
    max-width: 400px;
    margin: 0 auto 0 auto;
    text-align: center;
}


pnlSelection is slightly more interesting. The display property is grid, which instantly turns it into a grid container. grid-template-columns has a value of "1fr 1fr" which basically means two by two, and I've specified a 10-pixel gap. The text-align property has been set to justify to offset the text centering of its parent.

asset/css/styles.css
body
{
    font-size: 16px;
    font-family: georgia;
}

#pnlContainer
{
    min-height: 100vh;
    max-width: 400px;
    margin: 0 auto 0 auto;
    text-align: center;
}

#pnlSelection
{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    text-align: justify;
}


Now for pnlError and the button. pnlError has red text. The button is pill-shaped and has large text. Nothing to see here, move along.

asset/css/styles.css
body
{
    font-size: 16px;
    font-family: georgia;
}

#pnlContainer
{
    min-height: 100vh;
    max-width: 400px;
    margin: 0 auto 0 auto;
    text-align: center;
}

#pnlSelection
{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    text-align: justify;
}

#pnlError
{
    font-size: 0.8em;
    font-weight: bold;
    color: #FF0000;
}

button
{
    width: 100%;
    height: 2em;
    margin-top: 2em;
    border-radius: 20px;
}


Time for a preview! So far so good.


We're not just going to have a Chinese name transliterated - we're also going to select the languages it is transliterated in. For that, let's created options.js in the data folder of the assets folder. We basically export an array of objects. Each object has two properties - name and label. These are the five romanization systems to be transliterated in - Wade-Giles, Hokkien, Jyutping, Hangul and Vietnamese.

assets/data/options.js
const options = [
{
    value: "wade-giles",
    label: "Wade-Giles",
},
{
    value: "hokkien",
    label: "Hokkien",
},
{
    value: "jyutping",
    label: "Cantonese",
},
{
    value: "hangul",
    label: "Korean",
},
{
    value: "vietnamese",
    label: "Vietnamese",
}
];

module.exports = options;


We also have to make sure it's available in the back-end. Declare options as what options.js exports, then pass it into home as options.

app.js
const options = require("./assets/data/options.js");
var express = require("express");

var app = express();

var handlebars = require("express-handlebars").create({defaultLayout:"main"});
app.engine("handlebars", handlebars.engine);

app.set("view engine", "handlebars");
app.set("port", process.env.PORT || 3000);

app.use(express.static("assets"));

app.get("/", (req, res)=> {
  res.render("home", { options: options });
});

app.use((req, res, next)=> {
  res.status(404);
  res.render("404");
});

app.use((err, req, res, next)=> {
  res.status(500);
  res.render("500", { errorMessage: err.code });
});

app.listen(app.get("port"), ()=> {

});


Now in the view, we want to iterate through options, which has been passed down to this view from app.js. For each option, we'll have a div and a span. The first div will contain a checkbox and label, with values reflecting those of the option. The span tag will have an id that is "result_" concatenated with the name property of the current element of options.

views/home.handlebars
<div id="pnlSelection">
  <div><label for="txtFullChineseName">Full Chinese Name</label></div>
  <div><input id="txtFullChineseName" maxlength="4" /></div>

  {{#each options}}
  <div>
     <label>
       <input type="checkbox" name="languages" value="{{value}}">
       {{label}}
     </label>
  </div>
  <div>
     <span class="txtResult" id="result_{{value}}"></span>
  </div>
  {{/each}}

</div>


That's how it looks like! We haven't styled the span tags yet, and they have no content at the moment, so they're invisible.


Now, we want to handle clicking! Here's some beginning code...

views/home.handlebars
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {

});
</script>


And then we want to start with validation. First, we clear the contents of pnlError. If the string given is not a valid Chinese name (remember the function we created earlier?), we set the contents of pnlError to a message, then exit early.
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {
  document.getElementById("pnlError").textContent = "";

  const name = document.getElementById("txtFullChineseName").value.trim();

  if (!isValidChineseName(name)) {
    document.getElementById("pnlError").textContent = "Please enter a Chinese name using Chinese characters only.";
    return;
  }

});
</script>


If no options are selected, we also set the error message and exit early. selectedOptions can be reused later.

views/home.handlebars
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {
  document.getElementById("pnlError").textContent = "";

  const name = document.getElementById("txtFullChineseName").value.trim();

  if (!isValidChineseName(name)) {
    document.getElementById("pnlError").textContent = "Please enter a Chinese name using Chinese characters only.";
    return;
  }

  const selectedOptions = document.querySelectorAll("input[name='languages']:checked");

  if (selectedOptions.length === 0) {
    document.getElementById("pnlError").textContent = "Please select at least one transliteration option.";
    return;
  }

});
</script>


See what happens when the name contains anything that isn't Chinese.


Or when no option are checked. I'll be using this name as an example for the rest of this tutorial.


Then we declare properties, which really is a comma-separated string of all values selected. For this, we iterate through selectedOptions using the map() method, grabbing the value property values and appending them using the join() method.

views/home.handlebars
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {
  document.getElementById("pnlError").textContent = "";

  const name = document.getElementById("txtFullChineseName").value.trim();

  if (!isValidChineseName(name)) {
    document.getElementById("pnlError").textContent = "Please enter a Chinese name using Chinese characters only.";
    return;
  }

  const selectedOptions = document.querySelectorAll("input[name='languages']:checked");

  if (selectedOptions.length === 0) {
    document.getElementById("pnlError").textContent = "Please select at least one transliteration option.";
    return;
  }

  let properties = [...selectedOptions]
  .map(x => `"${x.value}"`)
  .join(", ");

});
</script>


We'll create a prompt, prompt. For this, we want a valid JSON object that will contain named properties that the user has selected. So if the user checked on the checkboxes labelled "Cantonese" and "Korean", the resultant value inserted into prompt would be "jyutping,hangul".

views/home.handlebars
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {
  document.getElementById("pnlError").textContent = "";

  const name = document.getElementById("txtFullChineseName").value.trim();

  if (!isValidChineseName(name)) {
    document.getElementById("pnlError").textContent = "Please enter a Chinese name using Chinese characters only.";
    return;
  }

  const selectedOptions = document.querySelectorAll("input[name='languages']:checked");

  if (selectedOptions.length === 0) {
    document.getElementById("pnlError").textContent = "Please select at least one transliteration option.";
return;
  }

  let properties = [...selectedOptions]
  .map(x => `"${x.value}"`)
  .join(", ");

  let prompt = `Return only a valid JSON object. The object should contain the following properties only: ${properties}. The value of each property should be the equivalent romanized version of the Chinese name "${name}" (without intonations) only. In the event of ambiguity, pick the first reasonable result.`;
});
</script>


Then we use a Try-catch block to send the prompt to the transliterate route using the asynchronous fetch() function. We'll display an error if there's one.

views/home.handlebars
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {
  document.getElementById("pnlError").textContent = "";

  const name = document.getElementById("txtFullChineseName").value.trim();

  if (!isValidChineseName(name)) {
    document.getElementById("pnlError").textContent = "Please enter a Chinese name using Chinese characters only.";
    return;
  }

  const selectedOptions = document.querySelectorAll("input[name='languages']:checked");

  if (selectedOptions.length === 0) {
    document.getElementById("pnlError").textContent = "Please select at least one transliteration option.";
return;
  }

  let properties = [...selectedOptions]
  .map(x => `"${x.value}"`)
  .join(", ");

  let prompt = `Return only a valid JSON object. The object should contain the following properties only: ${properties}. The value of each property should be the equivalent English romanized version of the Chinese name "${name}" (without intonations) only. In the event of ambiguity, pick the first reasonable result.`;

  try {
    const response = await fetch("/transliterate", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        prompt: prompt
      })
    });
  } catch (err) {
    document.getElementById("pnlError").textContent = "An unexpected error occurred.";
    console.error(err);
  }

});
</script>


Now let's handle the transliterate route. First, we set up the api.js file. org and key are derived from the OpenAI API project you should have set up for this project. Obviously my org and key values aren't "xx", but you'll have to get your own.

api.js
module.exports = {
  org: "xx",
  key: "xx"
}


Now, in app.js, define api as the exported value from api.js. Ensure that app uses the json() method from express. That's what we'll use to parse. And then define the transliterate route. It's POST, and async.

app.js
const api = require("./api.js");
const options = require("./assets/data/options.js");
var express = require("express");

var app = express();

var handlebars = require("express-handlebars").create({defaultLayout:"main"});
app.engine("handlebars", handlebars.engine);

app.set("view engine", "handlebars");
app.set("port", process.env.PORT || 3000);

app.use(express.static("assets"));

app.get("/", (req, res)=> {
  res.render("home", { options: options });
});

app.use(express.json());

app.post("/transliterate", async (req, res) => {

});


app.use((req, res, next)=> {
  res.status(404);
  res.render("404");
});

app.use((err, req, res, next)=> {
  res.status(500);
  res.render("500", { errorMessage: err.code });
});

app.listen(app.get("port"), ()=> {

});


We have a Try-catch block here.
app.post("/transliterate", async (req, res) => {
  try {

  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }

});


We obtain prompt by getting it from the body object of the request, req. If prompt does not exist, we exit early with an error.

app.js
app.post("/transliterate", async (req, res) => {
  try {
    const prompt = req.body.prompt;
  
    if (!prompt) {
      return res.status(400).json({
        success: false,
        error: "Prompt is required."
      });
    }

  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }
});


So here, we create response. It is what's returned from running fetch() with OpenAI's API responses endpoint, The operation is a POST and the headers passed in are Authorization, which uses the key value from api; and Content-Type which is set to accept JSON.

app.js
app.post("/transliterate", async (req, res) => {
  try {
    const prompt = req.body.prompt;
  
    if (!prompt) {
      return res.status(400).json({
        success: false,
        error: "Prompt is required."
      });
    }
  
    const response = await fetch(
      "https://api.openai.com/v1/responses",
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${api.key}`,
          "Content-Type": "application/json"
        }
      }
    );

  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }
});


Then in the body, we send a JSON-encoded string of an object containing the model and input. The model is "gpt-5" and the value for input is set to the string known as prompt. In effect, we're asking the API endpoint to provided romanizations for the provided Chinese name, in the various selected languages.

app.js
app.post("/transliterate", async (req, res) => {
  try {
    const prompt = req.body.prompt;
  
    if (!prompt) {
      return res.status(400).json({
        success: false,
        error: "Prompt is required."
      });
    }
  
    const response = await fetch(
      "https://api.openai.com/v1/responses",
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${api.key}`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          model: "gpt-5",
          input: prompt
        })

      }
    );
  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }
});


Since we used await for the previous line, the next part will have response ready. If the ok property of response is false or does not exist, exit with an error.

app.js
app.post("/transliterate", async (req, res) => {
  try {
    const prompt = req.body.prompt;
  
    if (!prompt) {
      return res.status(400).json({
        success: false,
        error: "Prompt is required."
      });
    }
  
    const response = await fetch(
      "https://api.openai.com/v1/responses",
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${api.key}`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          model: "gpt-5",
          input: prompt
        })
      }
    );
  
    if (!response.ok) {
      const errorText = await response.text();
  
      return res.status(response.status).json({
        success: false,
        error: errorText
      });  
    }

  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }
});


At this point, if there has been no early exit, that means the call was successful. We define data by converting it to JSON using the async method json(). Then we return the data. It looks convoluted right now because that's the way OpenAI wraps it - the text property of the first object of the content array, which in turn is the second element of the output array of data.

app.js
app.post("/transliterate", async (req, res) => {
  try {
    const prompt = req.body.prompt;
  
    if (!prompt) {
      return res.status(400).json({
        success: false,
        error: "Prompt is required."
      });
    }
  
    const response = await fetch(
      "https://api.openai.com/v1/responses",
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${api.key}`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          model: "gpt-5",
          input: prompt
        })
      }
    );
  
    if (!response.ok) {
      const errorText = await response.text();
  
      return res.status(response.status).json({
        success: false,
        error: errorText
      });
    }
  
    const data = await response.json();

    res.json({
      success: true,
      result: data.output[1].content[0].text
    });

  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }
});


Back to the view! We define data as the JSON shape of response. If success was not defined, then we set the error message in pnlError and exit early.

home.handlebars
try {
  const response = await fetch("/transliterate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      prompt: prompt
    })
  });

  const data = await response.json();

  if (!data.success) {
    document.getElementById("pnlError").textContent = data.error;
    return;
  }

} catch (err) {
  document.getElementById("pnlError").textContent = "An unexpected error occurred.";
  console.error(err);
}


Define transliterations. It'll be the object data's result object. Then we have txtResult, declared as a collection of all elements in the DOM using the CSS class txtResult.

home.handlebars
try {
  const response = await fetch("/transliterate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      prompt: prompt
    })
  });

  const data = await response.json();

  if (!data.success) {
    document.getElementById("pnlError").textContent = data.error;
    return;
  }

  const transliterations = JSON.parse(data.result);

  const txtResult = document.getElementsByClassName("txtResult");

} catch (err) {
  document.getElementById("pnlError").textContent = "An unexpected error occurred.";
  console.error(err);
}


We convert it to an array, then run a forEach() to iterate through it. x is the current element. We get the id and strip "result_" from it to get the language that it's supposed to be in. Then from there, we populate x with its relevant transliteration, if it exists.

home.handlebars
try {
  const response = await fetch("/transliterate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      prompt: prompt
    })
  });

  const data = await response.json();

  if (!data.success) {
    document.getElementById("pnlError").textContent = data.error;
    return;
  }

  const transliterations = JSON.parse(data.result);

  const txtResult = document.getElementsByClassName("txtResult");

  Array.from(txtResult).forEach((x) => {
    let propName = x.id.replace("result_", "");
    x.textContent = (transliterations[propName] ? transliterations[propName] : "");
  });

} catch (err) {
  document.getElementById("pnlError").textContent = "An unexpected error occurred.";
  console.error(err);
}


Try this! Enter a name. Then select a couple of languages. Let's try Wade-Giles, Cantonese and Korean.


It works! It takes a bit of time, but it works. In Wade-Giles, the name is "Tu Yu-lei", and so on.



Here's an improvement...

Add this to the layout file. It's another div, id pnlOverlay. In it, you have a message and an hourglass icon.

views/layouts/main.handlebars
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Chinese Name Transliterator</title>

    <link rel="stylesheet" type="text/css" href="css/styles.css">
    <script type="text/javascript" src="js/functions.js"></script>
  </head>
  <body>
    <div id="pnlOverlay">
      <p><h1>Please wait.</h1>This may take<br />a minute...</p>
      <p class="hourglass">⧗</p>
    </div>

      
    <div id="pnlContainer">
      {{{ body }}}    
    </div>
  </body>
</html>


Style it this way. The position property is fixed, and we anchor it to the top left corner of the screen, with full height and width, a translucent black background and white text. Classic overlay. Of course, we have to set display to none, to hide it. The hourglass CSS class just makes sure the font for this is huge.

assets/css/styles.css
#pnlOverlay
{
    position: fixed;
    display: none;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.9);
    color: rgb(255, 255, 255);
    text-align: center;
}


#pnlContainer
{
    min-height: 100vh;
    max-width: 400px;
    margin: 0 auto 0 auto;
    text-align: center;
}

#pnlSelection
{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    text-align: justify;
}

#pnlError
{
    font-size: 0.8em;
    font-weight: bold;
    color: #FF0000;
}

.txtResult
{
    font-weight: bold;
    color: rgb(100, 100,100);
}

.hourglass
{
    font-size: 5em;
}


button
{
    width: 100%;
    height: 2em;
    margin-top: 2em;
    border-radius: 20px;
}


For the heck of it, throw in an animation!

assets/css/styles.css
#pnlOverlay
{
    position: fixed;
    display: none;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.9);
    color: rgb(255, 255, 255);
    text-align: center;
    animation: pulse 1s infinite;
}

#pnlContainer
{
    min-height: 100vh;
    max-width: 400px;
    margin: 0 auto 0 auto;
    text-align: center;
}

#pnlSelection
{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    text-align: justify;
}

#pnlError
{
    font-size: 0.8em;
    font-weight: bold;
    color: #FF0000;
}

.txtResult
{
    font-weight: bold;
    color: rgb(100, 100,100);
}

.hourglass
{
    font-size: 5em;
}

@keyframes pulse
{
    0%
    {
        color: rgb(255, 255, 255);
    }
    50%
    {
        color: rgb(100, 100, 100);
    }
}


button
{
    width: 100%;
    height: 2em;
    margin-top: 2em;
    border-radius: 20px;
}


Now, add this in the view. When the button is clicked, pnlOverlay should pop up just before the Try-catch block.

views/home.handlebars
let properties = [...selectedOptions]
.map(x => `"${x.value}"`)
.join(", ");

let prompt = `Return only a valid JSON object. The object should contain the following properties only: ${properties}. The value of each property should be the equivalent romanized version of the Chinese name "${name}" (without intonations) only. In the event of ambiguity, pick the first reasonable result.`;

document.getElementById("pnlOverlay").style.display = "block";
  
try {
  const response = await fetch("/transliterate", {


And once there's a resolution, it should disappear!

views/home.handlebars
try {
  const response = await fetch("/transliterate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      prompt: prompt
    })
  });

  document.getElementById("pnlOverlay").style.display = "none";

  const data = await response.json();

  if (!data.success) {
    document.getElementById("pnlError").textContent = data.error;
    return;
  }

  const transliterations = JSON.parse(data.result);

  const txtResult = document.getElementsByClassName("txtResult");

  Array.from(txtResult).forEach((x) => {
    let propName = x.id.replace("result_", "");
    x.innerHTML = (transliterations[propName] ? transliterations[propName] : "");
  });
} catch (err) {
  document.getElementById("pnlError").textContent = "An unexpected error occurred.";
  console.error(err);
}


Here, let's try this.


There, it politely tells you to wait!


And then it shows you the results.


We're done here!

Just another fun NodeJS project. Combining my love for writing code, with exploring new human languages.

Talk Too U-lei-ter!
T___T

Monday, 13 July 2026

Hiring remote foreigners? Slow your roll, hotshot

"If I allow remote work, I'd be better off hiring programmers in Vietnam."

Sound familiar? Because that is the sound of millions of employers all over the world repeating the same old tired line. If I had a penny every time I heard some small-time Towkay-wannabe mouth off about how they'd save so much money by hiring foreign workers remotely rather than letting local talent work from home, I'd be absolutely rolling in it.

If I had a penny...

If you think this makes me sound dismissive, that's because I am. I am dismissing this line as nothing but bluster and hot air. And sit tight; I'm also about to explain why... in the Singapore context, naturally.

When it makes sense

If it's a huge MNC like, say, IBM, whose head office is in the USA and opening a regional office in Singapore, then yes, it makes total sense. Because they're already doing it. Their hiring and legal infrastructures are made for this. Even if the company is not at the scale of a large corporation, but still a respectable size, it might still make sense. It has to; plenty of these companies are doing it. The alternative is that all of them went nuts at exactly the same time... which is scarily plausible in these crazy times.

A game of expansion.

When hiring is done by the book, the host country (in this case, Singapore) and the remote country (in this case, Vietnam), both have agencies that will handle the legalities. I know because I've worked as an Agency Contractor before. These are absolutely a thing.

And of course, these agencies aren't doing it out of the goodness of their hearts. They're a business; they expect to get paid. This will eat into whatever employers think they're saving by hiring foreign workers, remote or otherwise. But that's OK - even if these tech companies don't save that much per foreign worker they hire, at scale, it tends to add up. Enormously.

When it makes less sense

A tiny tech startup trying to follow in the footsteps of a large corporation without understanding basic realities of scale, is on what I would charitably refer to as a "fool's errand". How many would such a company hire? Five programmers? Maybe ten? Whatever the company saves in wages, is nowhere what a larger company saves when hiring hundreds of these people. And if that pittance actually counts as a win, I would be deeply concerned about the finances of said company.

You sure you can
follow in those
footsteps?

I remember a time when Steve Jobs was considered the man to emulate. He was known to be a bit of an asshole, but he had undeniable talent, and thus he succeeded in spite of being hard to work for, or with. People took it to mean that in order to be as successful as Steve Jobs, one had to be an asshole. The end result was that we had quite a few Jobs-wannabes - basically talentless assholes with big dreams and even bigger mouths.

My point is, imitation may be the highest form of flattery; but for those ill-equipped to carry out said imitation, it becomes a liability quickly.

Also, consider this: any employer who isn't comfortable with employees working remotely on an island as small as Singapore, will be significantly less comfortable with employees working remotely out of Singapore.

If your employer is threatening to stop all this WFH nonsense by hiring more WFH programmers from beyond Singapore's shores, it doesn't take a genius to understand that the math does not quite add up.

When it starts being stupid

And it's at this point, where some employers will go, "Well, I'll just skip the red tape and hire direct from Vietnam, then."

Really? You're going to bypass all the legal and pertinent protections that hiring through the proper channels brings you?

How well do you know your own local employees, much less ones you hire from overseas? Do you know their residential address? And even assuming it's a verifiable genuine address, have you ever tried to navigate Vietnam using just a residential address without any understanding of the local language and conventions?

Pray tell, if the foreign software developer whom you don't know from Adam (or Minh, or Rajasamy) decides to sell your IP to the highest bidder, introduce malware into your code base or do any manner of damaging and illegal acts for profit or simply for amusement, how well do you expect to enforce your legal rights? Remember, they're not operating in Singapore, your home turf. Unlike local employees, these are foreign developers under a different jurisdiction where legal enforcement is already significantly harder - now made even more difficult because, y'know, you so cleverly bypassed the proper channels.

That's just the most extreme cases. Let's consider some less extreme but still very real considerations.

Time zones aren't that big an issue. This being Southeast Asia, assuming the employer is not adventurous (or foolhardy) enough to hire from further abroad, the time differences are off by a couple hours at most - an hour tops in the case of Vietnam. It'll need work, but it can work.

What's arguably a bigger issue, is culture. Just because you are all presumably communicating in English does not mean you speak the same language. Nuance gets lost in translation, especially if you're speaking through a computer screen. Honestly, colleagues have enough problems just communicating face-to-face, in the same culture, in the same language.

Don't cut through
this red tape.

And the most relevant question of all: Why, in the name of all that's right and holy, would anyone put themselves in this position just to save a few thousand bucks a month? Does a company that needs to save that kind of chump change so badly, really inspire confidence in you as an employee?

Anyone old enough to run a business, is too old to be this fucking dumb.

Hiring foreigners isn't foolish inherently. Hiring foreigners remotely isn't either, though significantly riskier. Hiring foreigners remotely without going through the proper channels? Reckless, and potentially illegal. Don't do it.

Conclusion and disclaimer

This is not meant to be any kind of statement against Vietnam. Vietnam absolutely does have a wealth of tech talent at affordable prices. I've worked with quite a few. I was only using Vietnam as a convenient example. I could easily have said Myanmar or India.

My point isn't that employers shouldn't hire foreigners remotely. My point is that it's not something that can just be hand-waved. Employers who have the experience and capability to carry this out, won't be talking about it because they are already - as Nike likes to say - just doing it. Or at the very least, taking cautious first steps. And the ones who are bringing it up casually as leverage during a discussion about remote work, will in all probability continue to just talk.

Your employer is not stupid. But if he's using hiring remote foreigners as a threat, he probably really hopes you are.

Tạm biệt hẹn gặp lại,
T___T

Tuesday, 7 July 2026

Spot The Bug: It's a Bug! It's a Plane!

Well hello, readers! Time for some more Spot The Bug!

Go away or be
squashed, bugs!


This episode revolved around NodeJS. My code was supposed to read a CSV file and display the contents in table format. The most basic shit ever. Somehow I managed to screw it up!

This was the script that would be executed. Basically, I would load the file tbl.csv, from the file system, and parse it as CSV text. And make sure each row got pushed into an array. Then after that, a HTML table would be created from the CSV rows, and inserted into the view.

app.js
var express = require("express");

var app = express();

var handlebars = require("express-handlebars").create({defaultLayout:"main"});
app.engine("handlebars", handlebars.engine);

app.set("view engine", "handlebars");
app.set("port", process.env.PORT || 3000);

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.use(express.static("assets"));

const fs = require("fs");
const csv = require("csv-parser");

let csvContent = [];

function loadFileCSV(filePath) {
   return new Promise((resolve, reject) => {
      const resultsCSV = [];

      fs.createReadStream(filePath)
      .pipe(csv())
      .on("data", (row) => {
         resultsCSV.push(row);
      })
      .on("end", () => resolve(resultsCSV))
      .on("error", (err) => reject(err));
   });
}

async function loadTableCSV() {
   try {
     csvContent = await loadFileCSV("assets/csv/tbl.csv");
   } catch (err) {
     throw new Error("Error reading CSV.");
     console.error("Error reading CSV:", err);
   }
}  

app.get("/superman", (req, res)=> {
  loadTableCSV();
  
  let csvTable = "<table><tr><td>Title</t><td>Year</t><td>Actor</t></tr>";

  for (let i = 0; i < csvContent.length; i++)
  {
    csvTable += "<tr><td>" + csvContent[i].Title + "</t><td>" + csvContent[i].Year + "</t><td>" + csvContent[i].Actor + "</t></tr>";
  }  

  csvTable += "</table>";

  res.render("superman", { table: csvTable });
});

app.use((req, res, next)=> {
  res.status(404);
  res.render("404");
});

app.use((err, req, res, next)=> {
  res.status(500);
  res.render("500", { errorMessage: err.code });
});

app.listen(app.get("port"), ()=> {

});


This was the view.

views/layouts/main.handlebars
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Superman</title>

    <link rel="stylesheet" type="text/css" href="css/styles.css">
  </head>
  <body>
    <h1>SUPERMAN DATA</h1>
    <div class="content">
      {{{ body }}}  
    </div>    
  </body>
</html>


views/superman.handlebars
<div>
{{{ table }}}
</div>

And this was the data.

assets/csv/tbl.csv
Title,Year,Actor
Superman/Atom Man vs. Superman,1948-1950,Kirk Alyn
Superman and the Mole Men/Adventures of Superman,1951-1958,George Reeves
The Adventures of Superboy (Pilot),1961,Johnny Rockwell
It's a Bird... It's a Plane... It's Superman,1975,David Wilson
Superman I-IV,1978-1987,Christopher Reeve
Superboy (TV Series),1988-1992,J.H. Newton/G. Christopher
Lois & Clark: The New Adventures of Superman,1993-1997,Dean Cain
Smallville,2001-2011,Tom Welling
Superman Returns,2006,Brandon Routh
Man of Steel/BvS/Justice League,2013-2017,Henry Cavill
Supergirl/Arrowverse/Superman & Lois,2016-2024,Tyler Hoechlin
Titans,2019-2023,Joshua Orpin
The Flash,2023,Nicolas Cage
Superman,2025,David Corenswet


What Went Wrong

So... wow. Totally nothing. No error, even. Why?



Why It Went Wrong

Notice that loadTableCSV() is an async function. Which means any call to loadTableCSV() better be async as well, or nothing will be returned. What happened was that I called loadTableCSV() and then went on to use csvContent without realizing that its value was as I initialized - an empty array. Because I didn't wait for the CSV data to load before using csvContent!
app.get("/superman", (req, res)=> {
  loadTableCSV();


Eventually, if I waited long enough, the data might show. But that would be scant consolation and something was still obviously wrong.

How I Fixed It

As it turned out, that was all that was needed. Change the callback signature to an async one, and the function call to loadTableCSV(), to an await function call.
app.get("/superman", async(req, res)=> {
  await loadTableCSV();


And the Superman table appeared!


Moral of the Story

Async is a tricky business. JavaScript, being async, is a tricky business by extension. And when you have NodeJS which is JavaScript in the front and back, it's a doubly tricky business.

Up up and away,
T___T

Friday, 26 June 2026

Five Ways Users Self-sabotage When Reporting Problems

In desktop support and in software development, there runs a common thread - responding to requests for help from users. And this remains some of the greatest tests of patience known to man. Not because the problems are tricky, though sometimes they can be. Not because the users are rude, though on occasion, a couple would greatly benefit from some goddamn manners. No, because sometimes, the way users report problems, just does not help themselves.

Let's go through a few ways that users' requests for help inspires intense rage could be improved.

1. "It doesn't work."

This is probably one of the most exasperating things a techie can hear. Because that phrase, and its numerous variations, basically tells you there's a problem, perhaps a general area, and that's all. It doesn't tell you anything else. No, the user informs you that there's a problem, and expects you to figure everything else from that mysterious statement.

Printer is on fire.

For example, telling us "help, I can't print" is a statement about as useful as a can of diesel to Elon Musk. "I can't print" could mean anything.

- the printer printed blank sheets of paper
- the printout was garbled.
- the print button on the screen did not respond to clicks.
- the print button could be clicked but nothing happened.
- the print button flashed, beeped, triggered the Fire Alarm and rebooted your life.

For extra aggravation points, the user might even write an email with a few paragraphs while including no information as to what the problem is, beyond "we're unable to print", ending with "we would appreciate your help as soon as possible". That in no way sounds like they want our help as soon as possible. In fact, it feels distinctively like they'd rather we take our own sweet time to decipher what the problem actually is, before we do a damn thing.

How To Do Better: Users should actually describe the damn problem beyond "it doesn't work". Because if it did work, there wouldn't be a need for this conversation.

2. What are we looking at?

The user tells you what they see on screen. Sometimes they even send you a screenshot. Neither of which is helpful, because what you see isn't an error message. It's something that looks perfectly innocuous on screen, and only the user knows why it's wrong.

Except the user has neglected to tell you exactly what is wrong. No, apparently having a tech degree means that you can somehow infer what the problem is, in the absence of any other indicators. Which leads me to think that the problem is not that systems have bugs. It's that system users think tech is actually witchcraft.

Not witchcraft.

So if you see a series of numbers in a column, you're supposed to magically know one of those numbers is supposed to be negative instead of positive. If you're sent a screenshot of a message that says "Record Saved", you're supposed to instinctively know that the problem was that the record was, in fact, not saved. Or perhaps the problem was that the record was saved when it shouldn't have been. Or perhaps the problem was that it was spelled in English, when the user's settings were in some other language. Who can tell?

What is with all this vagueness? Do users realize that if techies were capable of reading minds, we wouldn't be working here?

How To Do Better: Users should tell us exactly what they expected would happen but didn't. Without that information, it's hard to tell whether anything is wrong in the first place.

3. How did they get there?

This is when the user tells you what went wrong, and maybe even why it's wrong, but neglects to explain how it came about. Which is kind of like Hansel and Gretel wandering around in some dark forest and neglecting to leave breadcrumbs.

Leave us a trail!

In order to properly diagnose the source of the problem, we need to trace exactly the steps that the user took that led to the problem. In certain cases, this can actually be a case of the user using the system in ways it was not meant to be used. For example, trying to log in to a system and being unable to... and failing to mention that the URL they're using is the UAT site's rather than Production's.

Some users want to avoid looking stupid and leave out that information, perhaps hoping that the air of mystery will help. If I need to point out the irony here, you haven't been at this job long enough.

How To Do Better: The user was doing something that led to this. Whatever the user was doing, needs to be properly articulated because it is probably relevant.

4. Screenshots

For some unfathomable reason, when there's relevant information in a string that needs to be passed to the software developer - such as an id, URL or IP Address - users seem to prefer sending them as screenshots.

Exasperating as all hell when there's info in there that you now have to type out character by character, and probably make a few typos in the process. Now with modern tools such as LLMs that can analyze the image, this has become only slightly less exasperating.

Don't send pictures
of text, please!

Why is this completely stupid? Well, when you're being asked for help, generally, you'd expect those asking for help to at least try to make it easier for you to help them.

Also, copying and pasting text is significantly easier than manually reproducing text from a screenshot. But copying and pasting text takes about the same amount of effort as pasting a screenshot. Now, I could understand if the users were inconveniencing me in order to make their own lives easier... I wouldn't like it, but I would at least understand it. However, at this juncture, they're inconveniencing me without actually making things easier for themselves, so this entire exercise of pasting screenshots remains a damn mystery. It has zero practical value.

The truly horrifying thing is that this isn't confined to laypersons - other techies do it as well. Yes, this brand of insanity isn't confined to people who aren't actually expected to know better.

Bonus points for writing stuff down in handwriting that would make a doctor proud and sending a photo of it, instead of typing it like a normal person.

How To Do Better: Unless the information is wholly visual - colors, layout, etc - and does not include text of any kind that needs to be used by the support - ids, ip addresses, dates, etc - screenshots should not be used as a primary means of communicating the problem.

5. Jumping to Conclusions

This is when the user self-diagnoses and ends up sending you on a wild-goose chase. To be fair, even techies are capable of jumping to wrong conclusions with alarming frequency... except that in the case of techies, their wrong conclusions tend to be directionally correct. In the same ballpark, even.

Laypersons, as mentioned, tend to treat tech like witchcraft, so their wrong conclusions can be comically inaccurate. I remember one fascinating incident where the user reported that the changes to the database were not persisting. The next point of escalation was to the database team, because naturally, support had no direct access to the data. After days of barking up the wrong tree, it turned out that the fault was in the front-end code - it cached the previous result even though the data had been updated, thus misleading the user into thinking that it was a database problem.

What users should do, in these cases, is stick to the facts. The user should have just reported the simple fact that her display was showing previous data even though she had already updated it. This would have set support further back on the path, but at least they would not have been miles ahead on the wrong path.

Barking up the
wrong tree.

In this instance, though, I blame the techies for taking the complaint at face value in the first place. When are we going to learn to stop giving users so much credit? If you consulted a doctor and proceeded to diagnose yourself while asking for medication, could you expect to be taken seriously?

How To Do Better: Skip the diagnosis. Lay out the issue properly. Nobody's interested in your opinion - in fact, it tends to muddy the waters. Stick to facts.

Don't make yourselves un-rescuable

If users want help from techies, they should try making it easy for techies to help them. Or, at least, stop making it so devilishly hard. When you report a problem, just try to put yourselves in the shoes of the ones receiving your report, and see if it makes any damn sense.

Whats your problem?
T___T

Saturday, 20 June 2026

Web Tutorial: Bus Arrival App, Redux

Two years ago, I took you through the creation if this charming little app that could show you when your bus was arriving. And I took it on a rigorous test run that lasted that duration. In the process, I discovered what worked, and what needed work. Today I am going to be taking you through some upgrades I have done.

Don't look so shocked. Software is not dead. It evolves. Especially useful software.

Some problems I fixed...
- added visuals for bus types and capacity
- simplified data flow
- color scheme

Let's get to work! We will first streamline the data flow.

The first thing we'll want to do is remove the JavaScript. This upgrade removes all JavaScript in lieu of UI simplicity. To that end, this entire section needs to go.
<script>
function showArrivalFor($bus)
{
    var hide = document.getElementsByClassName("arrival");

    for (var i = 0; i < hide.length; i++)
    {
        hide[i].style.display = "none";
    }

    var show = document.getElementById("arrival_" + $bus);
    show.style.display = "block";
}
</script>


Remove this entire div. We want everything to be visible when the data is fetched. No more strategically hiding some of it. This means there are less steps for the user to go through, reducing interface friction.
<div id="bus" style="display:<?php echo (count($buses) == 0 ? "none" : "block");?>">
    <h1>&#128655; BUS SERVICES</h1>
    <?php
    foreach($buses as $bus)
    {
    ?>
        <button onclick="showArrivalFor('<?php echo $bus->ServiceNo; ?>');">
        <?php
            echo $bus->ServiceNo;
        ?>
        </button>    
    <?php    
     }
     ?>
</div>

<br />


Remove this too. We won't need it any more.
#bus button
{
    background-color: rgb(50, 0, 0);
    color: rgb(255, 255, 255);
    border-radius: 5px;
    border: 3px solid rgba(0, 0, 0, 0.5);
    padding: 5px;
    width: 5em;
    font-size: 20px;
    font-weight: bold;
}


In this section, the div should still be styled using the arrival CSS class, but the style that hides it, should be removed, as well as the id attribute.
<?php
    foreach($buses as $bus)
    {
?>
    <div class="arrival">
        <h1> BUS <?php echo $bus->ServiceNo; ?> ARRIVAL TIMINGS</h1>
        <?php
            if ($bus->NextBus)
            {
                 echo "<h2>" . formatArrivalTime($bus->NextBus->EstimatedArrival) . "</h2>";
            }

            if ($bus->NextBus2)
            {
                echo "<h2>" . formatArrivalTime($bus->NextBus2->EstimatedArrival) . "</h2>";
            }

            if ($bus->NextBus3)
            {
                echo "<h2>" . formatArrivalTime($bus->NextBus3->EstimatedArrival) . "</h2>";
            }
        ?>
    </div>
<?php      
    }
?>  


So far so good....


Next, the Color Scheme

Now here's a bit of styling. I enlarged the front to almost double what it was previously, changed the background color to orange, and gave container a white translucent border.
body
{
    background-color: rgb(200, 150, 0);
    font-family: sans-serif;
    font-size: 25px;
}

#container
{
    border-radius: 20px;
    border: 3px solid rgba(255, 255, 255, 0.5);
    padding: 2em;
}


I also removed some of these properties. They're no longer necessary.
#container div
{
    /* border-radius: 20px; */
    /* border: 3px solid rgba(100, 0, 0, 0.2); */
    padding: 0.5em;
    color: rgb(0, 0, 0);
}


That wasn't too hard!


More changes. A couple things are happening here. I remove borders from the rendering of the textbox and button for a clean flat look. I also change the button and hover color.
#stop input
{
    border-radius: 5px;
    border: 0px solid rgba(0, 0, 0, 0);
    padding: 5px;
    width: 10em;
    height: 1em;
}

#stop button
{
    background-color: rgb(255, 200, 0);
    color: rgb(255, 255, 255);
    border-radius: 5px;
    border: 0px solid rgba(0, 0, 0, 0);
    padding: 5px;
    width: 10em;
}

#stop button:hover
{
    background-color: rgb(150, 50, 0);
}


While we're at it, let's declutter by removing these icons.
<!--<h1>&#128655; BUS STOP <?php echo $busStop;?></h1>-->
<h1>BUS STOP <?php echo $busStop;?></h1>


<!--<h1>&#128652; BUS <?php echo $bus->ServiceNo; ?> ARRIVAL TIMINGS</h1>-->
<h1>BUS <?php echo $bus->ServiceNo; ?> ARRIVAL TIMINGS</h1>


Looks cleaner now, doesn't it?


In fact, let's clean this up even more.
<!--<h1>BUS <?php echo $bus->ServiceNo; ?> ARRIVAL TIMINGS</h1>-->
<h1><?php echo $bus->ServiceNo; ?></h1>


In the CSS, we provision styling for all h1 tags in arrival. The important thing is that it's floated left. The rest is just aesthetics. I'm going for white text on a yellow background, with rounded corners.
#stop button:hover
{
    background-color: rgb(150, 50, 0);
}

.arrival h1
{
    background-color: rgb(255, 200, 0);
    color: rgb(255, 255, 255);
    border-radius: 5px;
    border: 0px solid rgba(0, 0, 0, 0.5);
    padding: 5px;
    width: 4em;
    height: 40px;
    font-size: 30px;
    font-weight: bold;
    float: left;
    text-align: center;
}

</style>


So now that long-ass title has been reduced to a number.


Now provision some styling for h2 tags. We want them to be a more rounded white block, and appear to the right of the bus number. Thus, floating left is a must.
.arrival h1
{
    background-color: rgb(255, 200, 0);
    color: rgb(255, 255, 255);
    border-radius: 5px;
    border: 0px solid rgba(0, 0, 0, 0.5);
    padding: 5px;
    width: 4em;
    height: 40px;
    font-size: 30px;
    font-weight: bold;
    float: left;
    text-align: center;
}

.arrival h2
{
    background-color: rgb(255, 255, 255);
    border-radius: 15px;
    border: 0px solid rgba(0, 0, 0, 0.5);
    padding: 5px;
    width: 7em;
    height: 40px;
    font-size: 25px;
    font-weight: bold;
    float: left;
    text-align: center;
    margin-left: 0.5em;
}

</style>


But oops, this is a mess.


Add this line here to make sure floats are cleared.
    <?php
        if ($bus->NextBus)
        {
            echo "<h2>" . formatArrivalTime($bus->NextBus->EstimatedArrival) . "</h2>";
        }

        if ($bus->NextBus2)
        {
            echo "<h2>" . formatArrivalTime($bus->NextBus2->EstimatedArrival) . "</h2>";
        }

        if ($bus->NextBus3)
        {
            echo "<h2>" . formatArrivalTime($bus->NextBus3->EstimatedArrival) . "</h2>";
        }
    ?>
</div>
<br style="clear: both" />


All better now.


Presenting additional data

The final part is here. I want to show bus type and capacity, which is data already present in the API response. I simply did not make use of it the last time. Time to address that oversight!

First, let's relocate the logic to a function, so that the heavy lifting gets concentrated in one place. We'll create busArrivalDisplay() shortly, and retain formatArrivalTime().
<?php
/*
    if ($bus->NextBus)
    {
        echo "<h2>" . formatArrivalTime($bus->NextBus->EstimatedArrival) . "</h2>";
    }

    if ($bus->NextBus2)
    {
        echo "<h2>" . formatArrivalTime($bus->NextBus2->EstimatedArrival) . "</h2>";
    }

    if ($bus->NextBus3)
    {
        echo "<h2>" . formatArrivalTime($bus->NextBus3->EstimatedArrival) . "</h2>";
    }
*/

    if ($bus->NextBus)
    {
        echo busArrivalDisplay($bus->NextBus);
    }

    if ($bus->NextBus2)
    {
        echo busArrivalDisplay($bus->NextBus2);
    }

    if ($bus->NextBus3)
    {
        echo busArrivalDisplay($bus->NextBus);
    }
?>


In here, we modify formatArrivalTime() slightly to replace "T". It may or may not come up, but why take the chance, eh? Then create busArrivalDisplay(), with obj as a parameter. obj will contain all the information you need. The classes here are based on the Load property in the returned response.
function formatArrivalTime($strTime)
{
    $newStr = str_replace("+08:00", "", $strTime);
    $newStr = str_replace("T", " ", $newStr);
    return date("h:i A", strtotime($newStr));
}

function busArrivalDisplay($obj)
{
    $html = "<h2 class='capacity_" . $obj->Load . "'>";
    $html .= formatArrivalTime($obj->EstimatedArrival);
    $html .= "</h2>";

    return $html;
}


We will make use of the various possible values of capacity. In the CSS, we define different colors for capacity. "SEA" means that there are seats, so the color is green. "SDA" means that there's standing space, so we use yellow. "LSD" means that there's limited standing space. The bus is almost full. So use deep red for this.
.arrival h2
{
    background-color: rgb(255, 255, 255);
    border-radius: 15px;
    border: 0px solid rgba(0, 0, 0, 0.5);
    padding: 5px;
    width: 7em;
    height: 40px;
    font-size: 25px;
    font-weight: bold;
    float: left;
    text-align: center;
    margin-left: 0.5em;
}

.capacity_SEA
{
    color: rgb(0, 200, 0);
}  

.capacity_SDA
{
    color: rgb(200, 200, 0);
}    

.capacity_LSD
{
    color: rgb(200, 0, 0);
}
</style>


So now we have differently-colored times.


Now for bus types. Basically, I only care about the difference between single and double decker buses. Therefore, all other bus types will just use the same image as the single decker bus.

I used some stock images for this. I actually have only two images. The others are all duplicates with different names.

(img)
icon_.png
icon_BD.png
icon SD.png



icon_DD.png


Then we add this line. This adds a transparent PNG, according to the bus type, to the information.
function busArrivalDisplay($obj)
{
    $html = "<h2 class='capacity_" . $obj->Load . "'>";
    $html .= "<img height='30' src='icon_" . $obj->Type . ".png' /> ";
    $html .= formatArrivalTime($obj->EstimatedArrival);
    $html .= "</h2>";

    return $html;
}


Beautiful!

Enjoy this version!

I really think it's more user-friendly, especially on mobile. Before this, I tested it on desktop, but it doesn't really make sense to do that because, well, if you're trying to look up bus arrival data outdoors, why would you be using a laptop? Yeah I know, I dropped the ball. It's on me. Hopefully this makes up for it!

Stay bus-y,
T___T