A React + TypeScript single-page app that provides small, focused Indian tax tools. Everything runs 100% client-side - no server, no uploads, no data leaves the browser.
- Stack: React 18, TypeScript, Vite, Tailwind CSS v3, shadcn/ui (Radix primitives), SheetJS (xlsx), react-dropzone, sql.js-httpvfs (client-side SQLite over HTTP), country-flag-icons
- Dev:
npm run dev- Vite dev server - Build:
npm run build-tsc -b && vite build - Lint/type-check:
npm run lint-tsc -b --noEmit(no ESLint configured) - Format:
npm run format(Prettier, writes) /npm run format:check(CI-safe). Prettier usesprettier-plugin-tailwindcssto sort classes;src/components/ui(generated shadcn) is ignored.
- No brand icons from lucide-react - they are deprecated. Use inline SVGs from simpleicons.org instead.
- No sample files bundled - users must supply their own broker exports;
sample.xlsxin the repo root is for local dev only, never import or reference it from source. - No "upcoming"/"coming soon" placeholders - the
UPCOMINGregistry export and any UI that renders disabled/greyed-out future tools has been removed. New tools ship when they are ready. - No comments unless the WHY is non-obvious; never explain what code does.
src/
components/ # Shared UI (AppSidebar, Overview, FileDropzone, tables, shadcn/ui)
lib/tax/ # Domain logic - parser, periods, report, format (no React)
tools/
registry.ts # TOOLS array + getTool() - the only place to register a tool
quarterly-capital-gains/ # First live tool
sbi-forex-rates/ # SBI forex card rates (reads remote SQLite)
lib/forex/db.ts # sql.js-httpvfs client for the remote sbi_rates.db
App.tsx # Top-level router (reads activeId, renders the right tool)
main.tsx
- Create
src/tools/<slug>/<ComponentName>.tsx. - Add an entry to
TOOLSin src/tools/registry.ts -id,name,shortName,description,icon(non-brand lucide icon),component. - Done - the sidebar and overview card appear automatically.
Heavy tools (e.g. anything pulling in a WASM engine) are
lazy()-loaded in the registry so their code stays out of the initial bundle;App.tsxwraps the rendered tool in<Suspense>.
- The committed SQLite file is the API. A separate scraper repo
(
sbi-forex-util) commitssbi_rates.db; the browser queries it in place viasql.js-httpvfsusing HTTP Range requests. Never introduce a server or hosted DB. - Data URL:
https://raw.githubusercontent.com/Ankitz007/sbi-forex-util/main/sbi_rates.db(raw.githubusercontent.com servesAccept-Ranges: bytes+ CORS*). - Use
serverMode: "full"- it uses synchronous XHR in the worker, so it needs no SharedArrayBuffer and no COOP/COEP headers (works on GitHub Pages). - Schema
forex_rates(id, currency, ticker, tt_buy/sell, bill_buy/sell, ftc_buy/sell, cn_buy/sell, date, category).dateis ISOYYYY-MM-DD. Categories are exactlyBELOW_10andBETWEEN_10_20. currencyis the full name ("AUSTRALIAN DOLLAR"); the 3-letter ISO code ("AUD") is inticker. Flag lookup and priority sorting must key onticker, notcurrency.- Tax-rate guidance (keep accurate). The tool converts foreign amounts using
SBI's TT Buying rate (TTBR), but the relevant date AND the governing law differ
by ITR schedule:
- Capital gains (Schedule CG): governed by Rule 115 - use the TTBR of the last day of the month immediately preceding the transaction date.
- Foreign assets (Schedule FA): Rule 115 does not apply; the CBDT ITR instructions require the TTBR on the relevant event / reporting date.
- The UI shows a red best-effort disclaimer at the top; never present values, tips or notes as authoritative.
- No em-dashes / en-dashes anywhere (UI copy, comments, docs). Use a plain
hyphen
-. - Benign console warning: sql.js-httpvfs logs "The server did not respond
with Accept-Ranges=bytes". raw.githubusercontent.com does honour Range
(206s), but sends no
Access-Control-Expose-Headers, so browser JS can't readAccept-Ranges/Content-Range. The warning is cosmetic and unfixable client-side; data loads correctly. - Vite gotcha (do not regress): sql.js-httpvfs is a UMD/CommonJS bundle, so
it MUST be pre-bundled -
optimizeDeps.include: ["sql.js-httpvfs"], NOTexclude. Excluding it breaks the dev server withdoesn't provide an export named 'createDbWorker'. The worker/wasm are imported with the?urlsuffix (sql.js-httpvfs/dist/sqlite.worker.js?url,.../sql-wasm.wasm?url) so they emit as hashed static assets that resolve underbase: "./". After changing optimizeDeps, clearnode_modules/.vite.
- shadcn/ui components live in
src/components/ui/; generate withnpx shadcn@latest add <component>if a new primitive is needed. - Lucide icons are fine for non-brand glyphs (LineChart, Landmark, X, etc.). For brand logos (GitHub etc.) use an inline SVG.
- Responsive layout: sidebar is a fixed drawer on mobile (
lg:prefix for desktop behaviour).
- Supported brokers: Zerodha and Groww only. The UI asks which broker first.
- Zerodha: one workbook (multiple sheets). Stocks + mutual funds both live in it; auto-detected.
- Groww: two separate files (Stocks, Mutual Funds); upload either or both.
- Sections detected heuristically from row shape, not hard-coded positions -
scanSections()in src/lib/tax/parser.ts turns a worksheet into titled sections with resolved column maps. - Statement-format layer src/lib/tax/formats.ts:
detectFormat()+ per-format extractors (zerodha,groww-stocks,groww-mf) that all emit the sameTrade[], each trade tagged with acategory(its table) and asection(its term). - Zerodha mutual funds is a two-sheet join. The
Tradewise Exits...sheet has dated MF lots but no STCG/LTCG or equity/debt label; theMutual Fundssheet has the classification (ST/LT x Equity/Debt) but no dates. Each dated lot is joined to the classification by full fund name (Symbol). We trust Zerodha's own buckets - no tax law is re-encoded; theRealized Profit Breakdownis used only for a non-fatal reconciliation warning. - Groww mutual funds two-column rule. A row carries the gain in a
Short Term-Capital Gainand/orLong Term-Capital Gaincolumn; each populated column becomes oneTrade. The asset class comes from the per-rowCategorycolumn (Equity/Debt (Specified)/Debt (Unspecified)), NOT the section title - Groww omits a title row before the last asset class and just repeats the header, so title-based bucketing would misfile it. - Buyback trades are excluded for every format.
- Report aggregation in src/lib/tax/report.ts.
buildStatementReports()returns aStatementReports: astocksreport, a combinedmfreport, oneCategoryReportper MF asset class (mfCategories), the grandcombinedreport, and aturnoverbreakdown. Advance-tax periods in src/lib/tax/periods.ts. - Output layout (three periodic tables). The UI renders at most three
period-by-period tables: Stocks (
QuarterlyTable), Mutual Funds (MutualFundsTable) and Total (QuarterlyTable, only when both stocks and MF are present). The MF table is a single card whose columns are grouped by asset class - each class (Equity,Debt, ...) contributes a pair of STCG/LTCG columns across a two-row header, the five advance-tax periods are the rows, and a trailing Total column plus footer total row close it out. There is no separate per-asset-class table. - Buy / Sell Value table (
TurnoverTable) ordering. One flat table orderedStocks, then each MF category, then aMutual Fundssubtotal row (shown only when it adds information: more than one MF category, or MF alongside stocks), then a grandTotalfooter. The footer sums the category rows only, so the subtotal is never double-counted. Driven byStatementReports.turnover(CategoryTurnover[]) +turnoverTotal. - Clear button. Shown once any file is loaded;
clearAll()resets every workbook/file-name slot, the value-column choice and the FY override, returning the tool to its empty state. Switching broker resets the same file state. - The report/table layer is broker-agnostic; only
formats.ts/parser.tsknow broker-specific labels. Extractors match columns by name and skip unknown sections so an unexpected layout degrades to a warning rather than a crash. All formats are validated against realsample/exports (Zerodhataxpnl-*.xlsx, GrowwStocks_*/Mutual_Funds_*).