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.
Role
Timeline
Team
Platform
Measured outcome
the key decision
Privacy is a display mode that has to cover every surface, including chart axes.
how deep do you want to go?
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.
What a viewer should still be able to learn
"Hide amounts" can mean several things. I compared three:
| Option | What the viewer learns | Problem |
|---|---|---|
| A. Blur the whole screen | Nothing | Nothing left to discuss |
| B. Hide the balance only | Most amounts, through labels and axes | Leaks through everything else |
| C. Replace every amount, keep everything else | The pattern only | Hard 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.
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.
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.
Asset to add
Screenshot pair (run locally with the synthetic sample file): Spending section, Daily Expenses chart, Hide amounts off then on, with the Y-axis rupee ticks still visible in the masked version
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.
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.
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:
- 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.
- A privacy default should survive a reload. If the safe state resets, the unsafe state is what a shared screen shows first.
- 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.
States, and what the importer gets wrong
Asset to add
Screenshot (run locally): upload screen in its four states, idle with the format hint, dragging, parsing, and the error banner
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.
| Finding | Consequence | Fix |
|---|---|---|
| A date the importer can't read becomes today's date | A broken row can land in "last 30 days" and look like a real, recent purchase | Put unreadable rows in a "needs review" list, never into the charts |
| Dime exports times in UTC; the importer reads them as local time | In Nepal (UTC+5:45), late-night purchases can shift to the previous day | Read the export as UTC and show it in local time |
| Only an exact "Income" counts as income | A lower-case "income" would be counted as spending | Read the type case-insensitively and flag unknown values |
| An empty category shows as a blank name | A nameless slice appears in the category charts | Show it as "Uncategorized" |
| "Change file" deletes stored data without asking | A mislabelled, destructive action | Rename it "Remove data…" with a confirm step |
| A failed save is silent | The dashboard looks saved but isn't | A 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.
Asset to add
Proposal wireframe (to draw): import review screen, 142 rows imported, 3 need review, each flagged row with its raw value, the reason, a date picker and Discard
How I'd check it, and notes on sources
Validation plan
- 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.
- Importer tests. Small fictional files covering each case above: good dates, broken dates, missing columns, lower-case types, empty categories, negative amounts.
- Time check. Log a purchase at a known time after midnight in Nepal, export it, and confirm the day and hour the app shows.
- 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.