Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"plugins": {
"marketplaces": ["PolicyEngine/policyengine-claude"],
"auto_install": ["dashboard-builder@policyengine-claude"]
}
}
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bunx vitest run
- run: bun run build
43 changes: 43 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local
.env

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

# bun
bun.lock

# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
44 changes: 44 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# va-reform-dashboard

An interactive calculator that lets users model the impact of reforms to the Child Tax Credit (CTC), Earned Income Tax Credit (EITC), and income tax rates at both the federal and Virginia state levels for Virginia residents. Users configure their household and adjust reform parameters, then see household-level impacts via line charts and summary metrics, plus statewide impacts via microsimulation.

## Architecture

- Next.js App Router with Tailwind CSS v4 and @policyengine/ui-kit theme
- @policyengine/ui-kit for standard UI components (Header, SidebarLayout, Tabs, MetricCard, charts, inputs)
- Custom Modal backend (gateway + worker pattern) for household and statewide microsimulation
- Frontend polling via @tanstack/react-query for async computation results
- Two endpoints: `household-impact` (fast, ~10-40s) and `statewide-impact` (slow, ~2-5 min microsimulation)

## Development

```bash
bun install
make dev # Deploy worker + start gateway + frontend (random port)
make dev-frontend # Frontend only (uses production API or NEXT_PUBLIC_API_URL)
make dev-backend # Gateway only (worker must already be deployed)
```

## Testing

```bash
make test # Frontend tests (vitest)
make test-backend # Backend tests (pytest)
```

## Build

```bash
make build
```

## Design standards
- Uses Tailwind CSS v4 with @policyengine/ui-kit/theme.css (single import for all tokens)
- @policyengine/ui-kit for all standard UI components
- Primary teal: `bg-teal-500` / `text-teal-500`
- Semantic colors: `bg-primary`, `text-foreground`, `text-muted-foreground`
- Font: Inter (via next/font/google)
- Sentence case for all headings
- Charts use `fill="var(--chart-1)"` for series colors
- Recharts axes use `niceTicks` with `domain={["auto", "auto"]}`
- Negative currency formatted as `-$100` not `$-100`
45 changes: 45 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
.PHONY: dev dev-frontend dev-backend deploy-worker
.PHONY: build test test-backend lint clean

# Deploy worker functions, then start gateway + frontend
dev:
@echo "Deploying worker functions..."
@unset MODAL_TOKEN_ID MODAL_TOKEN_SECRET && modal deploy backend/app.py
@echo "Starting gateway (ephemeral)..."
@modal serve backend/modal_app.py & MODAL_PID=$$!; \
sleep 5; \
MODAL_URL="https://policyengine--va-reform-dashboard-fastapi-app-dev.modal.run"; \
PORT=$$(python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()'); \
echo "Gateway: $$MODAL_URL"; \
echo "Frontend: http://localhost:$$PORT"; \
NEXT_PUBLIC_API_URL=$$MODAL_URL PORT=$$PORT bun run dev; \
kill $$MODAL_PID 2>/dev/null

# Frontend only (uses production API or NEXT_PUBLIC_API_URL if set)
dev-frontend:
@PORT=$$(python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()'); \
echo "Starting dev server on http://localhost:$$PORT"; \
PORT=$$PORT bun run dev

# Backend only (gateway in dev mode — worker must already be deployed)
dev-backend:
modal serve backend/modal_app.py

# Deploy worker functions to Modal (required before gateway can spawn jobs)
deploy-worker:
unset MODAL_TOKEN_ID MODAL_TOKEN_SECRET && modal deploy backend/app.py

build:
bun run build

test:
bunx vitest run

test-backend:
cd backend && uv run pytest

lint:
bun run lint

clean:
rm -rf .next node_modules
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Virginia tax and benefit reform calculator

An interactive PolicyEngine calculator for modeling the impact of federal and Virginia state tax and benefit reforms on households and statewide.

## Getting started

```bash
bun install
make dev
```

## Stack

- **Frontend:** Next.js 14 (App Router), Tailwind CSS v4, @policyengine/ui-kit, Recharts
- **Backend:** Modal (gateway + worker), policyengine-us
- **Testing:** Vitest
- **Deployment:** Vercel (frontend), Modal (backend)
89 changes: 89 additions & 0 deletions __tests__/formatters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest'
import {
formatCurrency,
formatCurrencySigned,
formatPercent,
formatPercentagePoints,
formatCompact,
tickCurrency,
tickPercent,
} from '../lib/formatters'

describe('formatCurrency', () => {
it('formats positive amounts', () => {
expect(formatCurrency(50000)).toBe('$50,000')
})

it('formats negative amounts with sign before dollar sign', () => {
// Intl.NumberFormat produces "-$100" not "$-100"
const result = formatCurrency(-100)
expect(result).toMatch(/^-\$100$/)
})

it('formats zero', () => {
expect(formatCurrency(0)).toBe('$0')
})
})

describe('formatCurrencySigned', () => {
it('adds + sign for positive values', () => {
expect(formatCurrencySigned(2500)).toBe('+$2,500')
})

it('keeps - sign for negative values', () => {
expect(formatCurrencySigned(-1000)).toMatch(/^-\$1,000$/)
})

it('formats zero without sign', () => {
expect(formatCurrencySigned(0)).toBe('$0')
})
})

describe('formatPercent', () => {
it('formats decimal as percent', () => {
expect(formatPercent(0.22)).toBe('22.0%')
})

it('formats zero', () => {
expect(formatPercent(0)).toBe('0.0%')
})
})

describe('formatPercentagePoints', () => {
it('formats positive change', () => {
expect(formatPercentagePoints(0.5)).toBe('+0.50 pp')
})

it('formats negative change', () => {
expect(formatPercentagePoints(-1.2)).toBe('-1.20 pp')
})
})

describe('formatCompact', () => {
it('formats millions', () => {
const result = formatCompact(3500000)
expect(result).toMatch(/3\.5M/)
})

it('formats thousands', () => {
const result = formatCompact(15000)
expect(result).toMatch(/15K/)
})
})

describe('tickCurrency', () => {
it('formats small values', () => {
expect(tickCurrency(5000)).toBe('$5,000')
})

it('uses compact format for millions', () => {
const result = tickCurrency(2000000)
expect(result).toMatch(/\$2(\.0)?M/)
})
})

describe('tickPercent', () => {
it('formats decimal as percent', () => {
expect(tickPercent(0.32)).toBe('32.0%')
})
})
Loading
Loading