An offline-first PWA for capturing trip expenses on an iPhone and reconciling them against the Defense Travel System (DTS) — checking category totals and the GTCC-vs-personal split.
No backend, no accounts, no sync. All data lives on the device (IndexedDB); the
only thing that leaves is a CSV you choose to email to yourself. It's built to
work in two places: as a live app (at home), and as an exported spreadsheet (at
the office, where phones aren't allowed). See SPEC.md for the full
product spec.
- Fast expense entry — category and payment dropdowns, GBP and USD side by side, date defaulting to today but freely editable.
- Dual currency, no conversion — GBP (receipt) and USD (DTS) are tracked separately and never summed together. An expense with GBP but no USD yet is flagged "USD pending."
- M&IE per-diem calculator — multi-segment (full/partial days × rates), USD only, always counted toward the Personal account.
- MILEAGE calculator — miles × rate computes the USD amount, but (unlike M&IE) each leg stays its own itemized row, since DTS shows mileage leg-by-leg rather than as one lump total. A toggle switches to manual USD/GBP entry when the calculator doesn't fit.
- DTS reconciliation — enter the USD totals DTS shows per category and per account (GTCC/Personal reimbursement); the app flags mismatches with a signed delta, and separately flags a row as "incomplete" (yellow) if any of its expenses are still USD-pending, since that comparison is premature. Mark each expense "entered in DTS" and filter the outstanding ones.
- Exports for the office — a formatted
.xlsx(ExcelJS) with the reconciliation tables — mismatches and incomplete rows highlighted — at the top and raw rows behind, plus a plain CSV (usd_incompletecolumn). Shared via the iOS share sheet (Mail) or downloaded; both usable as standalone spreadsheets. - Multi-trip — create/rename/delete trips from the header's trip switcher; each trip's expenses/M&IE segments/DTS totals are stored separately, and export is always scoped to whichever trip is active. A device always has at least one trip.
- Backup/restore — a single JSON file with every trip on the device (expenses, M&IE segments, DTS totals), for moving to a new phone. Restoring replaces every trip on the device; a confirmation step shows what will be replaced before it happens.
- Installable & offline — "Add to Home Screen"; works with no signal after first load. A dismissible toast surfaces when an update is ready to reload, or when the app is confirmed ready to work offline.
- Help tab — install steps plus an FAQ walking through the domain concepts above (USD pending vs. incomplete vs. mismatch, M&IE vs. MILEAGE, what "entered in DTS" does, and so on).
- Vite + React 19 + TypeScript (strict)
- localForage over IndexedDB
- vite-plugin-pwa (service worker, manifest)
- CSV export is hand-rolled (no dependency);
.xlsxvia ExcelJS, lazy-loaded and precached - Vitest + Testing Library, ESLint (flat config)
npm install
npm run dev # http://localhost:5173/dts-expense-tracker/The app is best viewed in a mobile viewport (Chrome/Safari DevTools device mode). Data persists across reloads via IndexedDB.
| Script | What it does |
|---|---|
npm run dev |
Vite dev server |
npm run build |
Type-check + production build → dist/ |
npm run preview |
Serve the production build (test PWA/offline here) |
npm run typecheck |
tsc without emitting |
npm run lint |
ESLint over the project |
npm test |
Run the Vitest suite once |
npm run test:watch |
Vitest in watch mode |
npm run coverage |
Test coverage report |
npm run gen-icons |
Regenerate the app icon (hedgehog) |
PWA note: the service worker only runs against a built app. To test install/offline behavior, run
npm run build && npm run previewand open it on the phone (localhost or HTTPS).
src/
types.ts Domain model + fixed category set; isUsdPending / isEntered
db.ts IndexedDB load/save (localForage), per-trip prefixed keys
useTrips.ts Trip list + active trip id; create/rename/delete/select
useTripData.ts One trip's data (keyed by tripId, reloads on trip switch)
lib/ Pure logic, no React — unit-tested
id.ts Shared id generator (useTrips / useTripData)
mie.ts M&IE per-diem math
mileage.ts MILEAGE calculator (miles × rate)
totals.ts By-category / by-account totals (GBP & USD separate)
reconcile.ts App-vs-DTS comparison (USD, cent tolerance)
report.ts Shared export model consumed by both exporters
csv.ts CSV export (per-trip filename)
xlsx.ts Formatted .xlsx export (ExcelJS, dynamically imported)
backup.ts Whole-device JSON backup/restore (all trips; v1→v2 migration)
format.ts Currency + date helpers; slugify (export filenames)
components/ One file per screen (Entry, List, M&IE, Totals, Export,
Help) + TripSwitcher (header) + UpdateToast (banner)
App.tsx Tab shell
scripts/gen-icons.mjs Rasterizes the hedgehog SVG to every icon size
(Playwright/headless Chromium)
The domain invariants that keep the tool honest (currencies never summed, M&IE
always USD→Personal, USD-only reconciliation, fixed category order, the
"USD pending" / "entered in DTS" rules) are documented in
CLAUDE.md and covered by tests in src/lib and src/*.test.ts.
npm test # unit tests for the lib layer + component tests
npm run coverageTests focus on the pure calculation/export layer (the parts that must be correct
to reconcile against DTS) — including an .xlsx round-trip that reads the
written workbook back — plus the interactive components (entry, list, totals,
export).
Deployed to GitHub Pages via GitHub Actions on every push to main:
https://dodsonmg.github.io/dts-expense-tracker/. vite.config.ts sets base
to /dts-expense-tracker/ for Pages; set VITE_BASE=/ for a root deploy
(Netlify / custom domain):
VITE_BASE=/ npm run buildCI (.github/workflows/ci.yml) runs lint, type-check, tests, and build on
pushes and PRs; deploy.yml publishes dist/ to Pages.
The app icon is regenerated with npm run gen-icons (needs
npx playwright install chromium once per machine — see scripts/gen-icons.mjs).
Phase 1 (MVP), Phase 2 (reconciliation), and Phase 3 (multi-trip +
robustness) are implemented and deployed. Remaining in SPEC.md:
- Phase 4 — nice-to-haves: receipt photos, interactive laptop import.