Field Notes

An expense tracker on a Mac hotkey, with a monthly total

A spreadsheet is fine until you have to open it. This expense tracker floats a form on Control-Option-E, saves it to SQLite, and totals last month on the 1st.

Three expenses logged from the hotkey, each form filled by keyboard and saved as a row in the database.

I kept expenses in a spreadsheet for a year. The spreadsheet was never the problem. Opening it was. By the time Numbers had loaded and I had scrolled to the last row and remembered which column held the category, the receipt was back in my pocket. September ended with eleven rows for a month that had forty purchases in it.

Expense tracker apps fix the friction by asking for an account, a bank login and a subscription. The job is four fields and a SUM. This flow keeps the four fields and the sum, and puts the form one keystroke away.

What it does

Control-Option-E opens a small form: amount, category, an optional note, an optional receipt file. Save writes one row to a local database and confirms with a notification. Every morning at 9:00 the flow checks the date, and on the 1st it adds up the previous month by category and opens the totals in a window.

The flow

Fig. 1Two Sections. The top one logs an expense from a hotkey; the bottom one runs every morning and only reaches the report on the 1st.

Log an expense

Keyboard Shortcut is set to ctrl+option+e with Consume Key on, so the app in front never sees the press. It needs Accessibility permission; without it nothing fires.

The form is a Mid-Flow Form, listed as Form in the Actions palette, named Log an expense. A hotkey cannot start the Form trigger, which waits for you to press Run, so the hotkey starts the flow and the form is its first step.

Fig. 2Four fields in the Fields editor. Amount is a required Number with a minimum of 0.01, Category is a Select.
FieldTypeRules
amountNumberRequired, minimum 0.01
categorySelectRequired. Groceries, Eating out, Transport, Household, Work, Other. Default Groceries
noteTextOptional
receiptFileOptional
Fig. 3The form as it floats over other apps. Save stays disabled until Amount parses as a number.

The panel stays on top, follows you to the active Space and works over full-screen apps. Validation runs before Save does anything. An empty amount keeps the button disabled, and so does 12,50: the number field wants a decimal point even on a Mac set to a comma locale, so type 12.50. Escape or Cancel ends the run cleanly and writes nothing.

Save to Database, named Save expense, writes to a table called expenses with four fields: amount = {{amount}}, category = {{category}}, note = {{note}}, receipt = {{receipt}}. Each value is a lone {{...}}, so it keeps the type the form gave it. The amount is stored as a number, not the text "12.5". A note you left empty arrives as null with the key present, so it is stored as NULL rather than skipped. The receipt column holds the file's path, not a copy of the file, so keep receipts somewhere they will not be cleaned up.

There is no schema step. The first save creates the table with those four columns plus the two Watchflows manages, id and created_at (a UTC timestamp). Notification then confirms with {{amount}} in {{category}}, row {{rowId}}.

A monthly total by category

The Schedule trigger has no monthly option. One node is one set of weekdays at one time. So this Schedule fires every day at 9:00, and Run SQL decides whether today is the day:

SELECT month,
       SUM(n) AS entries,
       printf('%.2f', SUM(spent)) AS total,
       group_concat('<tr><td>' || category || '</td><td>' || n || '</td><td>'
                    || printf('%.2f', spent) || '</td></tr>', '') AS table_rows
FROM (
  SELECT strftime('%Y-%m', created_at, 'localtime') AS month,
         category, COUNT(*) AS n, SUM(amount) AS spent
  FROM expenses
  WHERE strftime('%d', {{scheduledFor}}, 'localtime') = '01'
    AND strftime('%Y-%m', created_at, 'localtime')
      = strftime('%Y-%m', {{scheduledFor}}, 'localtime', 'start of month', '-1 month')
  GROUP BY category
  ORDER BY spent DESC
)
GROUP BY month
Fig. 4Run SQL, with the statement that returns one row on the 1st and nothing on any other day.

{{scheduledFor}} is the occurrence the Schedule is firing for, and Run SQL binds it as a value rather than pasting it into the text. On any day but the 1st the WHERE matches nothing and count is 0. On the 1st you get one row: the month, the number of entries, the total, and the table rows already built as HTML. That last part matters because HTML Template fills in {{...}} values and has no loops, so the SQL builds the rows and the template drops them in with {{records.0.table_rows}}.

The Condition The 1st? passes when count is greater than 0. Report page wraps the row in a small table, and HTML View, Show report, opens it in an ordinary window with a Done button that calls watchflows.dismiss().

Fig. 5The report lane run for October 1st: September's three expenses totalled by category, $124.58 in all, in the Show report window.
Paste into the Flow Builder

When I press Control-Option-E, show a form with a required amount, a category dropdown (Groceries, Eating out, Transport, Household, Work, Other), an optional note and an optional receipt file, and save it to an expenses table in a new Expenses database.

What bites

Leave a few seconds between entries. A second Control-Option-E within about five seconds of pressing Save does not open a new form: the Form node takes the identical keypress for an echo of the last one and saves the previous expense again. Give it a breath between entries until that is fixed.

created_at is UTC. An expense logged at 8pm on the 31st in California is already the 1st in UTC. Every date in the statement goes through 'localtime' so the months are yours.

A sleeping Mac. If the Mac is asleep at 9:00 on the 1st, the Schedule fires once when it wakes, with scheduledFor set to the occurrence it missed. Wake it any time on the 1st and the report still comes. Wake it on the 2nd after 9:00 and the missed occurrence is the 2nd's, so that month's report is skipped.

Arming asks every time. Run SQL is always marked destructive, because the statement could be a DELETE. Arming this flow shows a confirmation naming Last month by category, even though this statement only reads. See Destructive Actions.

An open report holds the flow. While a page is open the flow cannot run again, so the hotkey does nothing until you press Done. Give up after is 240 minutes: an unattended report closes after four hours of silence and that run is marked failed.

Don't test with the canvas Run button. It fires both triggers: the form appears, and Run SQL refuses because a hand-started run carries no scheduledFor. Open the SQL field in the Workbench instead, put a scheduledFor on the 1st in its Test Values, and press ▶ to run that one node against the real table.

After importing

  1. Select Save expense, open Database, choose New Database… and create Expenses. Point Last month by category at the same database.
  2. Grant Watchflows Accessibility permission if the hotkey is new to it.
  3. Arm the flow and confirm the prompt.

Variations

What's in this flow

2 triggers, one row each. Nodes are listed in the order a run reaches them; the canvas above shows where it branches.

Download this flow
  1. Keyboard ShortcutHotkey
  2. Mid-Flow FormLog an expense
  3. Save to DatabaseSave expense
  4. NotificationSaved
  1. ScheduleEvery morning
  2. Run SQLLast month by category
  3. ConditionThe 1st?
  4. HTML TemplateReport page
  5. HTML ViewShow report

Run this one yourself

Download the flow, double-click it, and Watchflows opens it on the canvas. Everything in this note is on the 14-day free trial.