boymath — Val Town Savings Tracker

Context

Personal savings tracker Val on Val Town. Helps track money saved throughout the day by comparing would-have-spent vs. actually-spent, direct amount entry, or quick-tap preset buttons. Records to SQLite; history page shows totals + chart.


Stack

  • Backend: Hono on Deno (Val Town serverless)
  • Frontend: React 18 + TailwindCSS (via cdn.twind.style)
  • Storage: SQLite via https://esm.town/v/stevekrouse/sqlite
  • Charts: Recharts via https://esm.sh/recharts@2.8.0?deps=react@18.2.0,react-dom@18.2.0

File Structure

backend/
  index.ts                  ← Hono entry point
  database/
    migrations.ts           ← CREATE TABLE
    queries.ts              ← insertSaving, getSavings, getDailyChart
  routes/
    savings.ts              ← POST /api/savings, GET /api/savings, GET /api/savings/chart
    static.ts               ← serve frontend/ files
frontend/
  index.html
  index.tsx                 ← React root, hash router (#/ vs #/history)
  components/
    App.tsx                 ← top-level router
    MainPage.tsx            ← main input page
    HistoryPage.tsx         ← history + chart page
    CoinExplosion.tsx       ← coin burst animation
shared/
  types.ts                  ← SavingsRecord, ChartDataPoint types

Database Schema

Table name: boymath_savings_1

CREATE TABLE IF NOT EXISTS boymath_savings_1 ( id INTEGER PRIMARY KEY AUTOINCREMENT, amount_cents INTEGER NOT NULL, -- always positive (UI blocks negative submission) recorded_at TEXT NOT NULL DEFAULT (datetime('now')) )

API Routes

MethodPathDescription
POST/api/savingsBody: { amount_cents: number }. Insert record. Returns the inserted row.
GET/api/savings?period=day|week|monthReturns { total_cents, records[] } for the period
GET/api/savings/chartReturns { days: ChartDataPoint[] } — last 30 days of daily totals + 7-day rolling avg

Frontend: Main Page

Visual layout (matching Figma)

  • Top section: Input 1 (would-have-spent) and Input 2 (actually-spent), right-aligned, large gray placeholder text. A minus (−) between them. Horizontal divider line beneath.
  • Amount Saved display: Large bold "$0.00" centered below divider — tappable for direct-input mode.
  • Submit button: Large gray circle with "$" sign.
  • Dashed divider
  • 8 preset buttons in 2 rows: $1 $2 $3 $4 / 5¢ 10¢ 20¢ 50¢ — yellow (#FFCF48) circles.
  • Dashed divider
  • History icon (bar chart icon) bottom-right → navigates to History page.

Three input modes

All modes write to a single amountCents: number state. Switching modes resets the previous mode's inputs.

Mode A — Subtraction (Input 1 & Input 2)

  • Two <input inputMode="numeric"> fields (triggers iOS number pad)
  • User types digits only; displayed as $X.XX (e.g. typing "300" → shows "$3.00")
  • Raw integer treated as cents: 300 → 300 cents → $3.00
  • amountCents = input1Cents - input2Cents
  • Activates when user focuses Input 1 or Input 2
  • Resets button accumulator and direct-input state

Mode B — Direct input

  • Tap the large Amount Saved display to focus it
  • Same digit-only formatting (300 → $3.00)
  • Activates when user taps the Amount Saved display
  • Resets Input 1/2 and button accumulator

Mode C — Preset buttons

  • Each button press adds its value to amountCents
  • Pressing $1 ten times → $10.00; buttons do not hold state, they're additive triggers
  • Activates on first button press; resets Input 1/2 and direct-input

Number formatting logic

// Raw integer treated as cents: 300 → $3.00, 1250 → $12.50 function centsFromDigits(digits: string): number { return parseInt(digits.replace(/\D/g, '') || '0', 10); } function formatCents(cents: number): string { const abs = Math.abs(cents); const sign = cents < 0 ? '-' : ''; return `${sign}$${(abs / 100).toFixed(2)}`; }

Negative amount behavior

  • If amountCents < 0: full background transitions to red, Submit button disabled + grayed out
  • Amount Saved display still shows the negative value (e.g. "-$1.50")

Amount ready for submission

  • If amountCents > 0: the Submit button turns #FFBC00.

Submit behavior

  1. amountCents > 0: POST /api/savings with { amount_cents: amountCents }
  2. On success: trigger <CoinExplosion> animation, then reset all state to $0.00

Coin explosion animation (CoinExplosion.tsx)

  • On trigger, render ~20 coin elements (🪙 emoji) absolutely positioned at the Submit button's center
  • Each coin animates outward with a unique random angle and distance using CSS @keyframes
  • Scale up → scale down + fade out over ~800ms
  • After animation ends, unmount the component

Frontend: History Page

Layout (same aesthetic as main page)

  • Back button (←) top-left to return to main page
  • Period selector: Three pill buttons — 1 Day, 1 Week, 1 Month
  • Total saved: Large display (same style as Amount Saved on main page)
  • Chart: Recharts ComposedChart — bars for daily totals + line overlay for 7-day rolling average
    • X-axis: dates; Y-axis: amount ($)
    • Bars: yellow-tinted; Line: accent yellow (#FFCF48)

Data fetching

  • On mount and period change: GET /api/savings?period=... → updates total display
  • Chart always loads last 30 days: GET /api/savings/chart

Backend: Chart Query Logic

getDailyChart() in queries.ts:

SELECT date(recorded_at) as day, SUM(amount_cents) as total_cents FROM boymath_savings_1 WHERE recorded_at >= date('now', '-30 days') GROUP BY date(recorded_at) ORDER BY day ASC

7-day rolling average computed in TypeScript after fetching.


Critical Implementation Notes

  • iOS Safari number pad: <input type="text" inputMode="numeric" pattern="[0-9]*"> — numeric keypad on iOS without decimal/negative keys
  • React pinning: All React imports use ?deps=react@18.2.0,react-dom@18.2.0. First line of every .tsx: /** @jsxImportSource https://esm.sh/react@18.2.0 */
  • No Hono serveStatic: Use readFile/serveFile from https://esm.town/v/std/utils/index.ts
  • Routing: Hash-based — window.location.hash === '#/history' switches view
  • Hono error unwrapping: app.onError((err) => Promise.reject(err)) at top level
  • Entry point export: export default app.fetch

Verification

  1. Open Val URL in iOS Safari → confirm numeric keypad appears for all inputs
  2. Test Mode A: type in Input 1 and Input 2 → Amount Saved shows correct difference
  3. Test Mode B: tap Amount Saved display → type digits → formats correctly
  4. Test Mode C: tap preset buttons multiple times → amount accumulates correctly
  5. Test negative amount: make Input 2 > Input 1 → background turns red, Submit disabled
  6. Test Submit: valid amount → coin explosion fires, everything resets to $0.00, record saved
  7. Open History page → toggle 1 Day / 1 Week / 1 Month → totals update correctly
  8. Verify chart renders with bars + line overlay