7c0h

Building a Web App with nothing

I recently had to solve a problem that I already faced before: I need an app to keep track of my daily expenses, it has to run on my phone (so I can enter expenses on the run before I forget), and pretty much nothing else.

Last time I scratched this itch I built an Android app that did exactly that, and it worked fine for what I wanted. But time has passed, I have forgotten almost everything I knew about the Android SDK, and I wanted to get this version of the app done from scratch in one hour - for a single CRUD application that doesn't do the UD part, this seemed more than reasonable. So this time I decided to go for good old HTML and plain JavaScript.

Building the interface

I decided to build the app in pure HTML - the requirements of my project are modest, and I didn't want to deal with setting up a server anyway (more on this later). I did want my app to look good or at least okay but, while I do have some art delusions, I was never particularly good at web design. Therefore, I am a fan of fast web design frameworks that take care of that for me, and this project is not the exception. I am weirdly fond of Pure.css but for this project I wanted something new and therefore I settled for good old Bootstrap.

If you have never used either of these frameworks, the concept is simple: you include their code in the header of your HTML file, you copy the template that better fits the look you're going for, and that's it. In my case that means that my empty template looked like this:

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Spendings</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
        rel="stylesheet" crossorigin="anonymous"
        integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN">
</head>
<body>
    <main>
        <div class="my-4"></div>
        <div class="container">
            <h2>New expense</h2>
            <form id="main_form">
                <!-- Here comes the form fields -->
            </form>
        </div>
        <div class="my-4"></div>
        <div class="container">
            <h2>Export expenses</h1>
            <div class="row">
                <div class="col text-center">
                    <form>
                        <button class="btn btn-primary" onClick="export_csv(); return false;";>
                            Export data to .CSV
                        </button>
                    </form>
                </div>
            </div>
        </div>
    </main>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
        crossorigin="anonymous"></script>
</body>
</html>

Once this was up and running it was time to add form elements. In my case that meant fields for the following information:

  • Expense: what it is that I actually bought.
  • Amount: how much I paid for it.
  • Date and Time: when did I buy it.
  • Category: under which category it should be stored.
  • Currency: in which currency is the expense - useful when I'm traveling back home.
  • Recurrent: this is a checkbox that I use for expenses that I'll have to re-enter every other month such as rent. I have not yet implemented this functionality, but at least I can already enter this into the database.
  • Two buttons: "Add expense" which would save this data, and "Export to CSV" to download the data saved so far.

The first three fields (expense, amount, and currency) look like this. Note that most of this code is a slightly edited version from the examples I copy-pasted from the official Bootstrap documentation:

<form id="main_form">
    <div class="mb-3">
        <label for="inputDesc" class="form-label" required>Description</label>
        <input type="text" class="form-control" id="inputDesc" required>
        <div class="invalid-feedback">Enter a description</div>
    </div>
    <div class="mb-3 row">
        <div class="col-sm-8 input-group" style="width: 66%;">
            <span class="input-group-text" id="descAmount">Amount</span>
            <input type="number" step=".01" class="form-control" id="inputAmount" required>
            <div class="invalid-feedback">Amount missing</div>
        </div>
        <div class="col-sm-4 input-group" style="width: 33%;">  
            <select class="form-select" id="inputCurrency">
                <option selected value="eur">EUR</option>
                <option value="usd">USD</option>
                <option value="ars">ARS</option>
            </select>
        </div>
    </div>
    <!-- A lot of other fields -->
    <div class="col text-center">
        <button type="submit" class="btn btn-primary"
        onClick="add_expense(event); return false;">
            Add expense
        </button>
    </div>

And with that we are done with the interface.

Storing the data

Web browsers have become stupidly powerful in the last years, meaning that they not only include a full programming language (JavaScript) but also at least three mechanisms for saving data in your device: Cookies, Web Storage, and IndexedDB.

This mostly works for my project: having a database in my phone saves me the trouble of having to set a database in a server somewhere, but there's still a risk of data loss if and when I clean my device's cache. The solution, as you may have seen above, is an "Export to CSV" button that I can use to save my data manually once in a while - I could have chosen to e-mail myself the data instead, but that's the kind of feature I'll implement once I have a need for it.

We now need to delve into JavaScript. Most of the code I ended up using came from this tutorial, with the only exception that my code doesn't really need a primary index so instead of writing:

const objectStore = db.createObjectStore("name", { keyPath: "myKey" });

I went for:

const objectStore = db.createObjectStore("name", { autoincrement: true });

Once you have implemented most of that code, you are ready to insert data into your database. In my case that's what the function add_expense is for and it looks like this:

function add_expense(event)
{
    // Validate the expense using the mechanism provided by Bootstrap
    // See https://getbootstrap.com/docs/5.3/forms/validation/
    form = document.getElementById("main_form");
    form.classList.add('was-validated');
    // These are the only two fields that need proper validation
    expense = document.getElementById("inputDesc").value;
    amount = document.getElementById("inputAmount").value;
    if (expense.length == 0 || amount.length == 0)
    {
        event.preventDefault();
        event.stopPropagation();
    }

    // Open a connection to the database
    const transaction = db.transaction(["spendings"], "readwrite");
    const objectStore = transaction.objectStore("spendings");
    // Collect the data from the form into a single record
    var data = {spending: expense,
                date: document.getElementById("inputDate").value,
                time: document.getElementById("inputTime").value,
                category: document.getElementById("inputCateg").value,
                amount: Number(amount),
                currency: document.getElementById("inputCurrency").value,
                recurrent: document.getElementById("checkRecurrent").checked};
    // Store this record in the database
    const request = objectStore.add(data)

    transaction.onerror = (sub_event) => {
        // This function is called if the insertion fails
        console.log("Error in inserting record!");
        alert("Could not save record");
        // Displays a (currently hidden) error message
        document.getElementById("warning_sign").style.display = 'block';
        event.preventDefault();
        event.stopPropagation();
    }
    transaction.oncomplete = (sub_event) => {
        // This function is called if the insertion succeeds
        console.log("Record added correctly");
        // Ensures that the hidden error message remains hidden, or hides it
        // if an insertion had previously failed and now succeeded
        document.getElementById("warning_sign").style.display = 'none';
    }
}

The function for creating the output CSV file is a simple combination of a call to retrieve every record in the database and this StackOverflow answer on how to generate a CSV file on the fly:

function export_csv(event)
{
    // Open a connection to the database
    const transaction = db.transaction(["spendings"], "readonly");
    const objectStore = transaction.objectStore("spendings");
    // Request all records
    const request = objectStore.getAll();
    transaction.oncomplete = (event) => {
        // Header for the CSV file
        let csvContent = "data:text/csv;charset=utf-8,";
        csvContent += "date,time,expense,amount,category,currency,recurrent\r\n";
        // Add every record in a comma-separated format.
        // Can you spot how many bugs this code fragment has?
        for (const row of request.result)
        {
            fields = [row['date'], row['time'], row['spending'],
                        row['amount'], row['category'],
                        row['currency'], row['recurrent']];
            csvContent += fields.join(",") + "\r\n";
        }
        var encodedUri = encodeURI(csvContent);
        window.open(encodedUri);
    }
}

The above-mentioned code has a fatal bug: I am building a CSV file by simply concatenating fields together with a "," in between, and that's likely to break under many circumstances: if my description has a "," in it, if my locale uses "," as the decimal separator, if my description has a newline character for whatever reason, and many more. I could solve this by escaping/replacing every "," in the input fields with something else, but let's instead learn the proper lesson here: do not build CSVs by hand in production!

Conclusion

And just like that we are done. I set out to get it done in an hour, and I almost made it - I had some issues with the database not initializing properly, which I ended up solving by bumping up the database version and forcing the database to rebuild itself. The glorious final version can be found following this link. This app saves all of your data in your local device, meaning that you can start using it right now. But you don't have to take my word for it. If you don't trust some random dude with a blog you can always check the source code yourself and make sure that the version I'm linking to is the same I described above. Isn't open source code great?

I can imagine a couple reasons why one would want to develop an app like this. Perhaps you need a working prototype that you want to put in the hands of alpha testers right now. Perhaps you want an app that has to work in exactly one device. Or maybe you have to develop in an environment where all you have is a text editor, a web browser, and nothing more. This would be unacceptable for a professional environment, but sometimes the problems you're trying to solve are so simple that scaling up in resources and costs doesn't make sense. Or maybe you would like to go down to the basics once in a while and realize that not every app needs React, a web server, and a cloud deployment.

I didn't manage to do all I set up to do. Some upgrades that I'm planning to add gradually are:

  • A fix to the CSV bug mentioned above.
  • A message letting you know that you expense was inserted successfully.
  • Implement the "Recuring expense" feature - I'm thinking of checking whenever you open the app whether the current month has any recurring expenses. If not, then the app asks you whether you want to insert them, one at a time (in case one of the expenses is no longer valid).
  • A hint of what the conversion rate for that day is, in case I don't want to save "I bought this in US Dollars" but rather "I bought this in US Dollars and that converts to this many Euros".

And finally, I should point out that this project was originally conceived as a proof of concept for streaming and coding at the same time. I have video of the whole experience but, given that the camera died on me halfway, you will have to rely on my word when I say that you aren't missing much.