DEV Community

Cover image for I Had 3 Spreadsheets with 10+ Tabs Organizing My Life. Now It's All Handled by 1 App.
Steven Wallace
Steven Wallace

Posted on

I Had 3 Spreadsheets with 10+ Tabs Organizing My Life. Now It's All Handled by 1 App.

Too many spreadsheets

Link to repo

For years my life was organized in Excel and it was spread across three spreadsheets. One for net worth and accounts, one for collections, and another for goals. Each one had a several tabs. Every month I'd open all three, cross-reference formulas that referenced other tabs in other files, and hope I hadn't broken a VLOOKUP somewhere.

It seemed fine at first because I had the information that I wanted saved somewhere, but it wasn't easy to follow or to remember to keep updated. I wanted one place to answer questions like:

  • What's my actual net worth right now, across every account?
  • Am I on pace for my retirement and financial goals?
  • When does my next CD mature, and what's my blended APY?
  • What do I actually own, physically and digitally, and what is it worth?
  • What's on my wishlist, and did I already buy it somewhere else in my collection?
  • What are my goals for the next 5 years?

So I built a Finance Life Planning app. It's a personal finance and life-tracking app that consolidates all three spreadsheets, and more, into a single React app.

Dashboard

An Overview

It's a web app deployed on Azure Static Web Apps that can also run as a desktop Electron app. To save on costs and to make it more portable, there is no database. The data model is a single .xlsx file, one sheet per entity, that is read and written with ExcelJS. On desktop, it's a local file. On the web, it is housed in Azure Blob Storage behind GitHub OAuth.

Excel as a database sounds like it came straight from 1997, but for a single-user personal app it has its benefits. It's a format I already understand and I can open the file in Excel any time I want to sanity-check something or do ad-hoc analysis. It also means that backup is just copying a file. There are no schema migrations, no ORM, and no hosted Postgres bill for a hobby app. I pay about $0.01/mo in Azure fees.

The Features

The app is organized into five sections in the sidebar:

Finance

  • Dashboard: Net worth summary, net worth over time, asset allocation breakdown, and upcoming CD maturities.
  • Budget: Income and expenses by category and frequency
  • Allocation: Target vs. actual asset class mix, so I can see if I drift from my plans.
  • Projection: Compound growth projections for each asset and income source, with per-asset growth-rate assumptions.

Accounts

  • Accounts: Every bank, investment, retirement, and HSA account, plus bonds, with full value history over time.
  • CDs: A certificate of deposit tracker with a maturity calendar and blended APY.
  • Crypto: Holdings with staking status, APY, and unlock dates.
  • Retirement: Holdings, fund allocation, and a withdrawal schedule.
  • Donations: charitable giving log by year and organization.
  • Debts: Mortgage, auto, student loans, and credit cards, with collateral values, equity calculations, and a rewards/points tracker.

Assets

  • Tangible Assets: My physical collection (books, vinyl, art) with cost basis and current value.
  • Digital Assets: The same thing for e-books, digital games, etc. (In 2022 I guess NFTs would have gone here).

Life

  • Goals: Financial goals, lifetime goals, and an education/certification roadmap.
  • Achievements: Awards and personal milestones.
  • Tasks: A to-do list with priority, categories, and due dates.
  • Research: Saved links and reference material by category.
  • Media: Reading, gaming, and film logs with ratings.
  • Wishlist: A cross-category wish list. Marking something "Purchased" automatically drops it into the matching collection so I don't have to enter the data twice.

Personal

  • Contacts: An address book with relationships and birthdays.
  • Personal Info: My own personal details.

There's also a Demo Mode toggle in the sidebar that swaps in built-in sample data instantly. It's helpful for making screenshots or demoing it to friends without leaking my personal data.

Media page

The Tech Stack

Layer Technology
Desktop shell Electron
Web host Azure Static Web Apps
API Azure Functions v4 (Node.js)
UI React + Vite
Data storage (web) Azure Blob Storage (single .xlsx file)
Data storage (desktop) Local .xlsx file
Excel read/write ExcelJS
Charts Recharts
Styling Tailwind CSS
Auth (web) GitHub OAuth via Azure Static Web Apps

The convenient part of this architecture is that src/api.js abstracts the storage layer completely. Every page just calls onSave(sheetName, row) or onDelete(sheetName, rowIndex). Whether that turns into an Electron IPC call or a fetch('/api/save-row') depends on which shell the app is running in. The page components handle each version of the app in the same way.

How to Add a New Tab

The app was built so that adding a new category of data, whether it is a new tab, a new Excel sheet, or a full CRUD page, is a repeatable, five-step process. It's part of how I ended up with the 20 tabs that I have now because implementing a new one is so easy.

Here is an example on how I would add a new tab for subscriptions (Netflix, gym membership, etc.) which I'm not currently tracking separately.

1. Define the columns in SHEET_COLUMNS in two places

The Excel sheet's column layout is defined once for the Electron main process and once for the Azure Functions backend, and they have to match. In electron/main.js and api/shared/excel.js:

Subscriptions: ['ID', 'Name', 'Category', 'Amount', 'Frequency', 'NextBillDate', 'Active'],
Enter fullscreen mode Exit fullscreen mode

This is the place where the the dual environment architecture adds implementation complexity because the layouts need to match in both places.

2. Add a schema in src/data/schemas.js

Every add/edit modal in the app is generated from a declarative field schema. No page creates its own form. The new sheet just needs an entry describing its fields:

Subscriptions: [
  { key: 'Name', label: 'Name', type: 'text', required: true },
  { key: 'Category', label: 'Category', type: 'select', required: true,
    options: ['Streaming', 'Software', 'Fitness', 'News', 'Other'] },
  { key: 'Amount', label: 'Amount ($)', type: 'number', required: true },
  { key: 'Frequency', label: 'Frequency', type: 'select', defaultValue: 'Monthly',
    options: ['Monthly', 'Annual'] },
  { key: 'NextBillDate', label: 'Next Bill Date', type: 'date' },
  { key: 'Active', label: 'Active', type: 'select', defaultValue: 'Yes', options: ['Yes', 'No'] },
],
Enter fullscreen mode Exit fullscreen mode

EntityForm reads this and renders a fully working add/edit modal, complete with validation and defaults, with zero custom form code.

3. Build the page component

The page itself only has to handle display. The CRUD code comes from a couple of shared hooks. A trimmed-down version would look like this:

import Modal from "../components/Modal";
import EntityForm from "../components/EntityForm";
import { SCHEMAS } from "../data/schemas";
import { useEntityModal } from "../hooks/useEntityModal";

export default function SubscriptionsPage({ data, onSave, onDelete }) {
  const modal = useEntityModal();
  const subs = data?.Subscriptions || [];

  async function handleSubmit(row) {
    await onSave("Subscriptions", row, row._rowIndex == null);
    modal.close();
  }

  async function handleDelete(row) {
    await onDelete("Subscriptions", row._rowIndex);
    modal.close();
  }

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h2 className="page-title">Subscriptions</h2>
        <button onClick={() => modal.openAdd({ Frequency: "Monthly", Active: "Yes" })} className="btn-primary">
          + Add Subscription
        </button>
      </div>

      <div className="card p-6">
        <table className="w-full text-sm">
          {/* ...table head + rows, click a row to open modal.openEdit(row) */}
        </table>
      </div>

      <Modal open={modal.open} onClose={modal.close} title={modal.isEditing ? "Edit Subscription" : "Add Subscription"}>
        <EntityForm
          schema={SCHEMAS.Subscriptions}
          initialValues={modal.editRow}
          isEditing={modal.isEditing}
          onSubmit={handleSubmit}
          onCancel={modal.close}
          onDelete={modal.isEditing ? () => handleDelete(modal.editRow) : undefined}
        />
      </Modal>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useEntityModal handles the open/close/add-vs-edit state, EntityForm handles rendering and validating the schema from step 2, and onSave/onDelete are passed down from App.jsx already connected to the correct storage backend. The page component doesn't need to know whether it's running in Electron or the browser.

4. Connect it to App.jsx

Import the page and add one line to the route map:

import SubscriptionsPage from "./pages/SubscriptionsPage";
// ...
const pageContent = {
  // ...alphabetical...
  subscriptions: <SubscriptionsPage {...props} />,
};
Enter fullscreen mode Exit fullscreen mode

5. Add it to Sidebar.jsx

Make a nav entry (id, label, icon) into the relevant section array, and it shows up right away in the sidebar.

That's all there is to it. No migrations, no backend route to write (save-row and delete-row are generic across every sheet), and no new API surface. It is the data shape, form shape, and display. It's why I've been able to keep adding tabs (crypto, credit card rewards, a wishlist that auto-files purchases into collections) whenever the idea comes to mind.

Budget page

What's next

I'm still adding to it, although there has been a lull in ideas so I figured that meant it was a good time to write the article about it. The main thing I want to improve is the projection page. I still prefer Engaging Data's FIRE calculator over what I have now. I'd also like to link the media pages with Letterboxd or TMBD so I can have media images. I'd be happy to hear if anyone made something similar or if they have ideas to improve it.

Link to repo

Top comments (0)