Skip to content
Contact

Case studies / Personal · May 2026

Showing the pattern, hiding the number

A personal finance visualiser. I added a mask so I could share charts without showing how much money I have, then found places where amounts still leaked through.

PrivacyData vizSolo buildPersonal tool · May 2026

Role

Design and build (solo)

Timeline

May 2026

Team

Solo

Platform

Desktop web

Measured outcome

None measured

the key decision

Privacy is a display mode that has to cover every surface, including chart axes.

how deep do you want to go?

Privacy
Diagram from the case study. Product screenshots are being added.
That's the brief. Switch to Story for the full argument, or Deep for states, iterations and implementation.
Chapter 01

I wanted to share the chart, not the balance

Dime is an open-source iPhone expense tracker, made by someone else. Logging a purchase takes seconds, and that is what a phone is good at. Reading patterns across months is harder on a small screen: which categories drift, when in the week I spend, whether a month was unusual.

In May 2026 I built a desktop-browser companion for my own use. It reads the CSV file that Dime exports and turns it into ten views of the same transactions. I call it Dime Insights here. No one else uses it.

Two days after the first version, I added one switch to the sidebar: Hide amounts. I wanted to show the charts, on a shared screen or in a portfolio like this one, without showing how much money I have.

This case is about that switch. It is also about the places it missed, which I found later.

Product screenshot, test company dataThe Overview with amounts hidden. Synthetic data.
Chapter 02

One file in, ten views out

The importer expects exactly one format: Dime's own CSV export, with five columns (date, note, amount, category and type). It is not a general bank-statement reader. The upload screen shows the expected format before you drop the file, so the contract is visible up front.

Transactions are saved in this browser's local storage, so a reload brings the dashboard back. There is no account and no sync. I haven't done a network audit, so I don't promise more than that: the data is kept in the browser.

Some details that matter for my use:

  • Rupees, grouped the Nepali way. Amounts print as Rs with lakh grouping (Rs 1,00,000), the format I read without thinking.
  • Desktop only. A fixed sidebar holds navigation and every global control. It is not designed for phones; the phone already has Dime.
  • Plain methods. The forecast is a straight trend line over past months, labelled as linear regression.
Dime (iOS) capture, budgets Settings → Export Data export .csv 5 columns, UTC Desktop browser tab Dime Insights 1 · parse the file 2 · normalise rows 3 · compute the views 4 · draw 10 sections, mask drop local storage saved this browser only save rehydrate Vercel static app code Transactions are kept in this browser; no network audit has been done.
DiagramContext. The export file is the whole integration: the phone captures, the browser explains, and transactions are kept in this browser. Diagram from the app's behaviour.
Chapter 03

What a viewer should still be able to learn

"Hide amounts" can mean several things. I compared three:

OptionWhat the viewer learnsProblem
A. Blur the whole screenNothingNothing left to discuss
B. Hide the balance onlyMost amounts, through labels and axesLeaks through everything else
C. Replace every amount, keep everything elseThe pattern onlyHard to make complete

I chose C. With the switch on, every formatted amount becomes a fixed placeholder, "———". Chart shapes, percentages, counts and dates stay. You can still see that May was 16% lower than April, which category is the largest, and which day of the week is expensive. You can't see what any of it adds up to.

A · Blur the screen Hides the number and the pattern. Nothing left to discuss. B · Hide the total only Balance• • • • MayRs. 931.67 1k 0 Hides one number; leaks the rest through labels and axes. C · Replace every amount (chosen) Balance——— vs April−16% JanMay Hides every amount; keeps shape, percentages, counts and dates. Viewer learns: nothing Viewer learns: most amounts Viewer learns: the pattern only
Reconstruction, fictional dataMasking alternatives. Option C keeps what is worth discussing and removes what is private. It is also the hardest to get complete, because every place that prints money must go through it. Reconstruction, synthetic data.

Two small choices made C work:

  • A fixed-width placeholder. Three dashes for every amount, so the mask doesn't reveal whether a number has two digits or six.
  • One formatter behind the switch. Money shown in a stat or a tooltip goes through one shared formatter that checks the switch. Adding the mask did not mean touching every chart separately.
Chapter 04

Where the mask missed

The sidebar said "Amounts hidden". But not every amount went through that formatter.

When I went back through the app for this write-up, I found that the first version of the mask missed several places:

  • Chart axes. Seventeen Y-axis scales across seven sections built their own "Rs.20k"-style labels instead of using the shared formatter. With the mask on, a viewer could still read the scale off the side of the chart, and from the scale, roughly how big every bar is.
  • Generated sentences. The insight cards put amounts straight into their sentences, before the mask can see them.
  • Bucket labels and what-if figures. The spending distribution labels its ranges in rupees, and the what-if forecast prints its difference as a rupee string.
Off May expensesRs. 931.67 vs April−16% Rows31 Daily Expenses bars daily · line 7-day rolling Rs.0Rs.20Rs.40 1 May14 May 8 MayRs. 34.10 On · "Amounts hidden" May expenses——— vs April−16% Rows31 Daily Expenses bars daily · line 7-day rolling Rs.0Rs.20Rs.40 1 May14 May 8 May——— 1 2 3 4
Reconstruction, fictional dataThe mask, off and on. (1) Stat values become dashes. (2) Tooltips are masked too. (3) The Y-axis still prints rupee ticks, because those labels were built separately. (4) Percentages, counts and dates stay, by design. Reconstruction, synthetic data.
Product screenshot, test company dataThe leak as it appeared: dashes in the stats, rupees on the axis. Synthetic data.

The mechanism was right. The coverage was not, because formatting money wasn't in one place yet. Some parts of the app formatted amounts on their own, and a switch can only hide what passes through it.

A second gap is quieter. The switch resets to off when the page reloads, so opening the app on a shared screen shows the numbers first.

Chapter 05

The Pro page was a test

The app has a Pricing page, three Pro-only sections and a few blurred Pro cards. None of it takes payments. There is no checkout, the plan buttons do nothing, and a "Test Pro" switch in the sidebar unlocks everything. I built it to try out how the features would be packaged, not to sell anything.

It taught me one thing worth keeping: a blurred card is not concealment, because the real chart is still drawn underneath. That doesn't matter for a test gate. It would matter for anything private, which is one more reason the amount mask replaces text instead of blurring it.

Chapter 06

Status and what I learned

Status: a personal tool, used only by me. All data on this page is synthetic.

Measured outcome: none. There are no other users and nothing to measure beyond my own use. A screen-by-screen check with the switch on is the first test in the plan below.

Three lessons:

  1. Privacy has to cover every surface. Values, tooltips, axes, generated sentences and labels all print money. A mask that covers most of them tells the viewer something false: that the screen is safe.
  2. A privacy default should survive a reload. If the safe state resets, the unsafe state is what a shared screen shows first.
  3. State the contract, and hold the file to it. The upload screen tells you which export it expects. The importer should be just as strict, and show what it couldn't read instead of guessing.
Deep dive 07

States, and what the importer gets wrong

Empty upload screen drop non-.csv: no feedback Parsing "Parsing your data…" drop / pick .csv Loaded dashboard ≥1 row · persist reload: rehydrate "Change file": deletes stored data, no confirm ParseError red banner 0 rows or parse error retry Loaded, unsaved looks identical save fails (storage full), silently reload → Empty Parallel regions (orthogonal to data state) Hide amounts Off ⇄ On resets to Off on reload (shows numbers first) Pro test toggle Off ⇄ On sidebar or Pricing resets on reload filter All · 30d · 90d · 6m · YTD relative to today custom range: not in the UI theme System → Light → Dark saved in the browser the only toggle that survives
DiagramThe app's states. The dashed state is the one you cannot see: the dashboard works, but a failed save means a reload returns to the upload screen. The toggles run in parallel, and only the theme survives a reload. Reconstruction from the app's behaviour.
Product screenshot, test company dataThe upload screen shows the expected export format before you drop anything.

What I found reading the code again (not yet reproduced)

The masking gaps in chapter 04 are the ones I'm sure of. Reading the importer again turned up more. I haven't reproduced these in a running browser, so I list them as findings to test, with the fix I'd make.

FindingConsequenceFix
A date the importer can't read becomes today's dateA broken row can land in "last 30 days" and look like a real, recent purchasePut unreadable rows in a "needs review" list, never into the charts
Dime exports times in UTC; the importer reads them as local timeIn Nepal (UTC+5:45), late-night purchases can shift to the previous dayRead the export as UTC and show it in local time
Only an exact "Income" counts as incomeA lower-case "income" would be counted as spendingRead the type case-insensitively and flag unknown values
An empty category shows as a blank nameA nameless slice appears in the category chartsShow it as "Uncategorized"
"Change file" deletes stored data without askingA mislabelled, destructive actionRename it "Remove data…" with a confirm step
A failed save is silentThe dashboard looks saved but isn'tA visible notice

The first one taught me the most. Forgiving input keeps the dashboard from ever crashing, but in a finance tool a plausible wrong date is worse than an error.

Archived exploration, not builtProposed, not built: an import review that makes excluded rows a visible number instead of a silent guess.
Deep dive 08

How I'd check it, and notes on sources

Validation plan

  1. Masking audit. Turn the switch on and capture all ten sections with the synthetic sample file. Pass means no readable amount on any screen: stats, tooltips, axes, sentences and labels. Target: zero.
  2. Importer tests. Small fictional files covering each case above: good dates, broken dates, missing columns, lower-case types, empty categories, negative amounts.
  3. Time check. Log a purchase at a known time after midnight in Nepal, export it, and confirm the day and hour the app shows.
  4. Cross-browser check. Load the same file in Chrome, Firefox and Safari and compare how many rows each one reads.

Fix order

One formatter for values, axes and generated text comes first, because it closes the whole class of masking gaps. Then keep the switch's state across reloads, and consider making hidden the default. After that, the import review.

Notes on sources

  • Dates, behaviour and the masking gaps come from the app's source and history (built 23 May 2026, mask added 25 May 2026).
  • "Hide amounts" was added so I could share charts without revealing how much money I have. That is my own account.
  • The Pricing page and Pro tier were a test. There were never payments, accounts or other users.
  • All figures and screenshots use synthetic data. None of my own transactions appear on this page.
Deep chapters (states, iterations, implementation) are hidden in Story mode. Switch to Deep at the top to read them.