Building a Personal Finance Tracker Part 2. From Dashboard to Dark Mode
Building a Personal Finance Tracker Part 2 — From Dashboard to Dark Mode
> We'll translate the architecture designed in Part 1 into actual code. This includes the dashboard UI, external API integration, data synchronization, dark mode, and responsiveness.
Setting Up the Frontend Framework
We created a React + TypeScript project using Vite, and the first thing we built was the layout.
Fixed header + bottom tab navigation. As a mobile-first app, we placed the 🍎 Pomme logo at the top and five tabs at the bottom. React Router renders different pages for each tab.
┌──────────────────────┐
│ 🍎 Pomme ☀ │ ← 고정 헤더 + 테마 토글
│ │
│ (페이지 컨텐츠) │
│ │
├──────────────────────┤
│ 📊 💼 💰 ⚑ ⚙ │ ← 하단 네비게이션
└──────────────────────┘
The project structure is divided by functionality. Components are separated into folders per page, while data logic is grouped in hooks and lib.
src/
├── components/
│ ├── Layout/ ← 헤더 + 네비 (공통)
│ ├── Dashboard/ ← 게이지, 요약
│ ├── Portfolio/ ← 종목 관리
│ ├── CashFlow/ ← 수입/지출 차트
│ ├── Roadmap/ ← 마일스톤 타임라인
│ └── Settings/ ← API 설정, 테마
├── hooks/ ← 상태 관리 + API fetching
├── lib/ ← API 클라이언트, 계산 로직, 저장
├── data/ ← 정적 데이터 (로드맵)
└── types/ ← TypeScript 타입 정의
Dashboard — The Heart of the App
The dashboard is the very reason this app exists. It must show "Where am I now?" the moment it opens.
TrackGauge Component
The core is two gauges. They display progress as a percentage, but every time the app opens, a count-up animation from 0 to the current value plays. An app you open daily shouldn't just show static numbers—that's boring.
There were three key implementation points.
Count-up animation. Using requestAnimationFrame, it climbs from 0 to the current percentage over about 1.5 seconds. An easing function is applied for a fast start and a gradual slowdown at the end.
Gauge gradient. Track A uses green tones (symbolizing growth), Track B uses gold tones (symbolizing income). Colors fill the area using CSS linear-gradient.
D-day counter. Days remaining until the target date. Simple, but seeing the number decrease daily provides motivation.
Income/Expense Summary
The bottom of the dashboard displays this month's income/expense summary. It processes data fetched from the Whoing API, which we'll cover later.
Portfolio Page — Real-time Quotes
The Portfolio tab manages your holdings. Enter ticker, quantity, and purchase price to fetch the current price via the Finnhub API and display your return.
Finnhub Integration
Finnhub API integration is surprisingly simple. Only two endpoints are used.
Real-time Quotes — GET /api/v1/quote?symbol=AAPL
Just extract the c (current price) from the response. This is used to calculate the return on investment with the held quantity and purchase price.
7-Day Candlestick Chart — GET /api/v1/stock/candle?symbol=AAPL&resolution;=D&from;=...&to;=...
Retrieves 7 days of closing prices per stock to draw a Sparkline (mini line chart). This allows you to see recent trends at a glance.
Exchange Rate Handling
Since it's a US stock, prices come in dollars. To convert to won, you need the exchange rate, which is fetched via the ExchangeRate-API. The server caches it for 6 hours, so API calls aren't excessive.
Track A Calculation Flow
종목별 (shares × 현재가) ← Finnhub quote
× USD/KRW 환율 ← ExchangeRate-API
+ 현금성 자산 잔액 ← 후잉 bs.json
─────────────────
= 트랙 A 총액
÷ 목표
= 게이지 퍼센트
Static data (held quantity) and dynamic data (market price, exchange rate, balance) from multiple sources combine to form a single gauge. These calculations are consolidated in calculations.ts and called from the Dashboard.
Whooing API Integration — Server Proxy
This step implements the server proxy pattern designed in Part 1.
Vercel Functions
Placing TypeScript files in the api/ folder triggers Vercel to automatically deploy them as serverless functions. Three functions were created.
api/whooing.ts — Whooing API Proxy
When the client sends a request in the format GET /api/whooing?endpoint=in_out.json§ion;_id=...&start;_date=..., the server adds the Whooing authentication header, calls the actual API, and returns the result.
Maintaining an endpoint whitelist is crucial. It only allows requests to permitted endpoints, preventing the client from calling arbitrary Whooing APIs.
허용: sections.json, accounts.json, in_out.json, mountain.json, bs.json
차단: 그 외 전부
api/exchange-rate.ts — Exchange Rate Proxy
Processes ExchangeRate-API responses into {rate, base, target, updatedAt} format for delivery. Caches for 6 hours via Cache-Control header.
api/data.ts — KV Read/Write
Endpoints for storing and reading app data in Vercel KV (Upstash Redis). Reads via GET and writes via POST.
Client Library
Client code calling the server proxy is grouped in src/lib/. Functions like getInOut() and getMountain() are created in whooing.ts, then wrapped in React hooks (useWhooingInOut, useWhooingMountain) for use in components.
컴포넌트 → useWhooingInOut(hook)
→ getInOut(lib)
→ fetch('/api/whooing?endpoint=in_out.json&...')
→ Vercel Function
→ 후잉 API
This layered approach means that if the API changes, only the library needs modification, and UI logic is separated from data logic.
The Pitfall of Whooing OAuth Authentication
The Whooing API is based on OAuth 1.0, but there's one interesting quirk. The signature field in the authentication header is misspelled as signiture. Because the Whooing server expects this exact typo, sending it with the correct spelling causes authentication to fail. When integrating the API, such pitfalls are hard to spot just by reading the documentation; you discover them only by actually making calls.
Cash Flow Page — Household Budget Visualization
The Cash Flow tab visualizes the income/expense data accumulated in Hwing. It is not for direct recording but a view for viewing existing household budget data.
Monthly Navigation
Use the left/right arrows to navigate months. Income/expense data for the selected month is fetched from Hwing's in_out.json.
Expense Bar Chart
Visualizes category-based expenses using Recharts BarChart. Fixed expenses, living expenses, and irregular expenses are visible at a glance.
Net Worth Trend Chart
The Hwing mountain.json API returns monthly net worth. This is plotted as a 12-month LineChart, with the target line (Track A goal) shown as a dotted line. You can visually track how close your current assets are to the target line.
Automatic Side Income Calculation
Manually entering Track B (My Name Monthly Income) is cumbersome. Since all income is already recorded in Hwing, it's now automatically calculated.
부수입 = 이번 달 후잉 총수입 - 본업 월급 항목
By designating the main job salary account ID in the household ledger, all other income excluding that item becomes "Money Earned Under My Name". You can set which account is the salary in Settings.
Cash Equivalents Sync
Cash equivalents (deposits, RP, etc.) are also fetched from Hooing's bs.json. Check the accounts you want to treat as cash equivalents in Settings, and their balances will be summed and reflected in Track A.
One important note here: Hwing's bs.json response structure differs from in_out.json. While in_out returns objects per account, bs uses <string, number=”“>a</string,> Record<string, number=""> format where account IDs map directly to numbers (balances). Don't assume the API response structure; it's crucial to inspect the actual response and match the types.
Roadmap Tab — The Power of Static Data
The fifth tab, Roadmap, operates differently from the others. It functions solely with static data, requiring no API calls.
Milestones documented in the financial principles are hardcoded into src/data/roadmap.ts and rendered via the UI. To change the data, simply modify this file.
UI Components
Action Plan Checklist. This is a monthly to-do list. Past items are automatically marked with a checkmark (✓) and strikethrough based on the current date. It uses simple logic comparing new Date() with milestone dates.
Track A Vertical Timeline. Lists quarterly milestones vertically and displays a "Current" badge at the current position. Key events (e.g., surpassing 100 million in assets) receive a separate highlight style.
Auto-Collapse Past Milestones. As the timeline grows, past items take up screen space. By default, past milestones are hidden and can be expanded via a "View Past N Milestones" button.
Don't underestimate static data. Showing users "where they are now and what's next" every time they open the app is as crucial as the core feature. This page delivers value without API integration.
Dark Mode — The True Value of CSS Variables
Since it's a personal app, dark mode is a matter of preference. Light mode uses an atelier tone (warm paper texture), while dark mode features warm gold accents.
CSS Variable Override
While there are multiple ways to implement dark mode, CSS variable override is the cleanest approach. Define light mode variables in :root and override them with [data-theme="dark"].
:root {
--color-bg: #F8F7F4;
--color-text: #1A1A1A;
--color-accent: #2D5A3D;
}
[data-theme="dark"] {
--color-bg: #111009;
--color-text: #ddd5c0;
--color-accent: #c8a97a;
}
In component CSS, just use var(--color-bg). Colors automatically update when the theme changes. No need to modify each component individually. However, hardcoded #fff or white values will break in dark mode. It's best to manage colors with CSS variables from the start.
Preventing Theme Flash
A common issue when using dark mode in React SPAs is the "flash" phenomenon: briefly seeing light mode during page load before switching to dark.
The solution is to add an inline script to index.html. Before React loads, it reads the theme setting from localStorage and pre-sets the data-theme attribute.
<script>
(function() {
var t = localStorage.getItem('pomme_settings');
if (t) { /* 파싱 후 data-theme 설정 */ }
})();
</script>
This script is `<head>Because it runs synchronously, the theme is applied before the first paint.
3-State Cycle Toggle
A toggle button cycling through Light → Dark → System → Light… was added to the header. The useTheme hook detects the OS's prefers-color-scheme media query and follows the OS setting when in system mode.
Responsive — Mobile First
This app is most frequently opened on phones during commutes. Mobile is the primary focus.
Strategy
Instead of a complex breakpoint system, we solved most cases with just clamp().
--font-gauge: clamp(40px, 12vw, 56px);
clamp(min, preferred, max) — scales naturally based on viewport width. 40px on 320px phones, 56px on 480px+ devices, and auto-adjusts between using 12vw.
We applied clamp only to five large fonts: gauge numbers, titles, portfolio total amount, etc. The rest of the text works fine at its default size.
Small Device Handling
We only set a separate breakpoint for devices with a 320px width, like the iPhone SE.
@media (max-width: 359px) {
.cash-flow-summary { grid-template-columns: 1fr 1fr; }
.form-row-2col { grid-template-columns: 1fr; }
}
The 3-column grid becomes 2 columns, and the 2-column input form becomes 1 column. This is sufficient.
Tips for Testing on Actual Devices
Running the Vite dev server with the --host option allows access from mobile devices on the same Wi-Fi network. Testing on actual devices by touching the screen is far more accurate than using an emulator.
npx vite --host
# → http://192.168.x.x:5173 에서 폰으로 접속
Vercel KV Synchronization — Implementation
This is the actual implementation of the Write-Through Cache pattern designed in Part 1.
Writing
When a user edits their portfolio, it's immediately saved to localStorage. Then, after a 1-second debounce, it syncs to the server. Sending it to the server instantly every time would trigger API calls with every keystroke.
유저 조작 → localStorage 즉시 저장
→ 1초 debounce
→ POST /api/data { portfolio, goals, settings, ... }
→ Vercel KV에 저장
Reading
When the app starts, it fetches the latest data from the server and overwrites localStorage. This ensures changes made on other devices are reflected.
앱 시작 → GET /api/data
→ 응답 데이터로 localStorage 갱신
→ 컴포넌트 렌더링
Graceful Degradation
What if the KV server is unreachable? It operates solely using localStorage. The app must function normally even offline or in local development environments without KV environment variables set. If syncFromServer() fails, it silently ignores it and uses local data.
Development Environment — Vite Mock
Vercel Functions only run in production. How do we handle local development?
We built a mock API using Vite's configureServer hook. It intercepts requests like /api/data, /api/whooing, and /api/exchange-rate in vite.config.ts and returns mock data from memory.
[로컬 개발]
/api/data → Vite mock (메모리 Map)
/api/whooing → Vite proxy → 실제 후잉 API (선택적)
[배포 환경]
/api/data → Vercel Function → Upstash KV
/api/whooing → Vercel Function → 후잉 API
This allows testing the entire flow locally before deployment. If you actually want to call the Whooing API, just add the key to .env.local and connect via proxy.
Part 2 Wrap-Up
Here's a summary of what we covered this time:
- Frontend Framework — Layout, Routing, Component Structure
- Dashboard — TrackGauge count-up, D-day
- Portfolio — Finnhub real-time quotes, currency conversion, Track A calculation
- Cash Flow — Hwing data visualization, automatic side income calculation, cash asset integration
- Roadmap — Static data-driven milestone timeline
- Dark Mode — CSS variable overrides, theme flash prevention
- Responsive Design — Fluid scaling with clamp(), single minimum breakpoint
- KV Synchronization — Write-through cache implementation, graceful degradation
- Development Environment — Vite mock API
At this point, the app is "functional." Running it locally fills the gauges, displays stock prices, and shows household ledger data in charts.
But it's not deployed yet. Part 3 covers authentication (since not just anyone should see my financial data), Vercel deployment, and connecting GitHub auto-deployment.</head></string,>