diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..d6c9537 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1ee1eda..5b2b43f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,8 @@ jobs: source .venv/bin/activate pre-commit run --all deactivate + - name: Run frontend tests + run: npm run test-ci - name: Run python tests run: | source .venv/bin/activate diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5524fe6..4feeb65 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,21 @@ repos: + # Run `npm run lint` + - repo: local + hooks: + - id: jslint + name: jslint + entry: npm run lint + language: system + files: \.(ts|tsx|js|jsx)$ + + - repo: local + hooks: + - id: tsc + name: tsc + entry: npm run type-check + language: system + pass_filenames: false + - repo: https://github.com/psf/black rev: 23.11.0 hooks: diff --git a/README.md b/README.md index 3265bc8..d69f3b3 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,23 @@ Details on the FEC data schema: https://www.fec.gov/campaign-finance-data/contri We're using `python 3.12`. Modern Macs have `clang >= 15.x` (mine has `17.0.4`). Alas, a transitive dependency (`multidict`) is currently broken in this environment and, also, does not ship binary wheels. Here's my solution: https://github.com/aio-libs/multidict/pull/877#issuecomment-1812948387 + +### Running locally + +You'll need the sqlite databases, of course (ask Dave). + +Then, you'll need to install the dependencies: + +``` +npm install +CFLAGS="-Wno-error=int-conversion" pip install -r requirements.txt +pip install -r requirements-dev.txt +``` + +Then, you'll need to run both the Vite front-end server AND the python+litestar backend server: + +``` +./scripts/run.sh +``` + +Pop open http://localhost:2222/ and you should see the app. diff --git a/index.html b/index.html index e4b78ea..7014c3e 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,21 @@ - - - - - Vite + React + TS - - -
- - - + + + + + + Team Against Trump + + + + + + +
+ + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4a645d2..9754046 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@headlessui/react": "^1.7.17", + "@heroicons/react": "^2.0.18", + "clsx": "^2.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -26,7 +29,8 @@ "pyright": "^1.1.336", "tailwindcss": "^3.3.5", "typescript": "^5.2.2", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^0.34.6" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -831,6 +835,29 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@headlessui/react": { + "version": "1.7.17", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.17.tgz", + "integrity": "sha512-4am+tzvkqDSSgiwrsEpGWqgGo9dz8qU5M3znCkC4PgkpY4HcCZzEDEvozltGGGHIKl9jbXbZPSH5TWn4sWJdow==", + "dependencies": { + "client-only": "^0.0.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "react-dom": "^16 || ^17 || ^18" + } + }, + "node_modules/@heroicons/react": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.0.18.tgz", + "integrity": "sha512-7TyMjRrZZMBPa+/5Y8lN0iyvUU/01PeMGX2+RE7cQWpEUIcb4QotzUObFkJDejj/HUH4qjP/eQ0gzzKs2f+6Yw==", + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -864,6 +891,18 @@ "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", "dev": true }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", @@ -1103,6 +1142,12 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1144,12 +1189,36 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chai": { + "version": "4.3.11", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.11.tgz", + "integrity": "sha512-qQR1dr2rGIHYlJulmr8Ioq3De0Le9E4MJ5AiaeAETJJpndT1uUNHsGFK3L/UIu+rbkQSdj8J/w2bCsBZc/Y5fQ==", + "dev": true + }, + "node_modules/@types/chai-subset": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.5.tgz", + "integrity": "sha512-c2mPnw+xHtXDoHmdtcCXGwyLMiauiAyxWMzhGpqHC4nqI/Y5G2XhTampslK2rb59kpcuHon03UH8W6iYUzw88A==", + "dev": true, + "dependencies": { + "@types/chai": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/node": { + "version": "20.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.2.tgz", + "integrity": "sha512-37MXfxkb0vuIlRKHNxwCkb60PNBpR94u4efQuN4JgIAm66zfCDXGSAFCef9XUWFovX2R1ok6Z7MHhtdVXXkkIw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", @@ -1402,6 +1471,101 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/@vitest/expect": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz", + "integrity": "sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==", + "dev": true, + "dependencies": { + "@vitest/spy": "0.34.6", + "@vitest/utils": "0.34.6", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.34.6.tgz", + "integrity": "sha512-1CUQgtJSLF47NnhN+F9X2ycxUP0kLHQ/JWvNHbeBfwW8CzEGgeskzNnHDyv1ieKTltuR6sdIHV+nmR6kPxQqzQ==", + "dev": true, + "dependencies": { + "@vitest/utils": "0.34.6", + "p-limit": "^4.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.34.6.tgz", + "integrity": "sha512-B3OZqYn6k4VaN011D+ve+AA4whM4QkcwcrwaKwAbyyvS/NB1hCWjFIBQxAQQSQir9/RtyAAGuq+4RJmbn2dH4w==", + "dev": true, + "dependencies": { + "magic-string": "^0.30.1", + "pathe": "^1.1.1", + "pretty-format": "^29.5.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.34.6.tgz", + "integrity": "sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ==", + "dev": true, + "dependencies": { + "tinyspy": "^2.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.34.6.tgz", + "integrity": "sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A==", + "dev": true, + "dependencies": { + "diff-sequences": "^29.4.3", + "loupe": "^2.3.6", + "pretty-format": "^29.5.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", @@ -1423,6 +1587,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.0.tgz", + "integrity": "sha512-FS7hV565M5l1R08MXqo8odwMTB02C2UqzB17RVgu9EyuYFBqJZ3/ZY97sQD5FewVu1UyDFc1yztUDrAwT0EypA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1500,6 +1673,15 @@ "node": ">=8" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/autoprefixer": { "version": "10.4.16", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", @@ -1606,6 +1788,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1644,6 +1835,24 @@ } ] }, + "node_modules/chai": { + "version": "4.3.10", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", + "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.0.8" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -1658,6 +1867,18 @@ "node": ">=4" } }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -1697,6 +1918,19 @@ "node": ">= 6" } }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, + "node_modules/clsx": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -1782,6 +2016,18 @@ } } }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1794,6 +2040,15 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "dev": true }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2311,6 +2566,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -2586,6 +2850,12 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "dev": true + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2623,6 +2893,18 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/local-pkg": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", + "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2655,6 +2937,15 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2664,6 +2955,18 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", + "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2698,6 +3001,18 @@ "node": "*" } }, + "node_modules/mlly": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.4.2.tgz", + "integrity": "sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg==", + "dev": true, + "dependencies": { + "acorn": "^8.10.0", + "pathe": "^1.1.1", + "pkg-types": "^1.0.3", + "ufo": "^1.3.0" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -2891,6 +3206,21 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.1.tgz", + "integrity": "sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==", + "dev": true + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -2927,6 +3257,17 @@ "node": ">= 6" } }, + "node_modules/pkg-types": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz", + "integrity": "sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==", + "dev": true, + "dependencies": { + "jsonc-parser": "^3.2.0", + "mlly": "^1.2.0", + "pathe": "^1.1.0" + } + }, "node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -3082,6 +3423,32 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3150,6 +3517,12 @@ "react": "^18.2.0" } }, + "node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, "node_modules/react-refresh": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", @@ -3344,6 +3717,12 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -3362,6 +3741,18 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.6.0.tgz", + "integrity": "sha512-aFZ19IgVmhdB2uX599ve2kE6BIE3YMnQ6Gp6BURhW/oIzpXGKr878TQfAQZn1+i0Flcc/UKUy1gOlcfaUBCryg==", + "dev": true + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -3386,6 +3777,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", + "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", + "dev": true, + "dependencies": { + "acorn": "^8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/sucrase": { "version": "3.34.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", @@ -3516,6 +3919,30 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.5.1.tgz", + "integrity": "sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==", + "dev": true + }, + "node_modules/tinypool": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.7.0.tgz", + "integrity": "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.0.tgz", + "integrity": "sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -3567,6 +3994,15 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -3592,6 +4028,18 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz", + "integrity": "sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==", + "dev": true + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/update-browserslist-db": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", @@ -3692,6 +4140,106 @@ } } }, + "node_modules/vite-node": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.34.6.tgz", + "integrity": "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "mlly": "^1.4.0", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": ">=v14.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.34.6.tgz", + "integrity": "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==", + "dev": true, + "dependencies": { + "@types/chai": "^4.3.5", + "@types/chai-subset": "^1.3.3", + "@types/node": "*", + "@vitest/expect": "0.34.6", + "@vitest/runner": "0.34.6", + "@vitest/snapshot": "0.34.6", + "@vitest/spy": "0.34.6", + "@vitest/utils": "0.34.6", + "acorn": "^8.9.0", + "acorn-walk": "^8.2.0", + "cac": "^6.7.14", + "chai": "^4.3.10", + "debug": "^4.3.4", + "local-pkg": "^0.4.3", + "magic-string": "^0.30.1", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.3.3", + "strip-literal": "^1.0.1", + "tinybench": "^2.5.0", + "tinypool": "^0.7.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0", + "vite-node": "0.34.6", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": ">=v14.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@vitest/browser": "*", + "@vitest/ui": "*", + "happy-dom": "*", + "jsdom": "*", + "playwright": "*", + "safaridriver": "*", + "webdriverio": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3707,6 +4255,22 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", + "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 955403a..5fc7d58 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,17 @@ "type": "module", "scripts": { "dev": "vite", + "test": "vitest", + "test-ci": "vitest run --reporter=src/utils/minimalReporter.ts", "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "type-check": "tsc --noEmit", "preview": "vite preview" }, "dependencies": { + "@headlessui/react": "^1.7.17", + "@heroicons/react": "^2.0.18", + "clsx": "^2.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -30,6 +36,7 @@ "pyright": "^1.1.336", "tailwindcss": "^3.3.5", "typescript": "^5.2.2", - "vite": "^5.0.0" + "vite": "^5.0.0", + "vitest": "^0.34.6" } } diff --git a/scripts/run.sh b/scripts/run.sh index 785f851..1576704 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -1,5 +1,18 @@ #!/bin/sh -# Run the application +trap gracefulshutdown SIGINT + +gracefulshutdown() { + echo "Gracefully shutting down..." + kill -TERM 0 +} + +# Run the python server export LITESTAR_APP=server.web:app -litestar run --reload +litestar run --reload --port 3333 & + +# Run the vite frontend +npm run dev & + +# Wait for all processes to finish +wait diff --git a/scripts/test.sh b/scripts/test.sh index accf27f..78352a8 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -1,4 +1,5 @@ #!/bin/sh pre-commit run --all-files +npm run test-ci python -m unittest discover -s server diff --git a/server/utils/format.py b/server/utils/format.py index 59f1f26..ca3202a 100644 --- a/server/utils/format.py +++ b/server/utils/format.py @@ -1,3 +1,3 @@ def fmt_usd(cents: int) -> str: """Format a value in cents as USD.""" - return f"${cents / 100:,.2f}" + return f"${cents / 100:,.0f}" diff --git a/server/web.py b/server/web.py index 87ecb6e..496c785 100644 --- a/server/web.py +++ b/server/web.py @@ -1,12 +1,16 @@ import pathlib +import tempfile +import typing as t -from litestar import Litestar, get +from litestar import Litestar, get, post +from litestar.datastructures import UploadFile +from litestar.enums import RequestEncodingType +from litestar.params import Body -from server.data.contacts import Contact +from server.data.contacts.abbu import ZipABBUManager +from server.data.contacts.google import GoogleContactExportManager from server.data.manager import DataManager -from server.data.models import get_engine -from server.data.nicknames import NicknamesManager -from server.data.summaries import ContributionSummaryManager +from server.data.search import ContactContributionSearcher @get("/") @@ -23,16 +27,43 @@ async def frontend(path: pathlib.Path) -> dict: return {"file": str(path)} -@get("/summarize", sync_to_thread=True) -def summarize() -> dict: - """Summarize somebody.""" - data_manager = DataManager.default() - nicknames_manager = NicknamesManager.from_data_manager(data_manager) - engine = get_engine(data_manager, "WA") - contact = Contact("MICHAEL", "MATHIEU", "SEATTLE", "WA", None, None) - summary_manager = ContributionSummaryManager(engine, nicknames_manager) - summary = summary_manager.preferred_summary_for_contact(contact) - return summary.to_data() if summary else {} +@post("/api/search") +async def search( + data: t.Annotated[UploadFile, Body(media_type=RequestEncodingType.MULTI_PART)] +) -> dict: + """Search a collection of contacts and summarize them.""" + is_zip = data.content_type == "application/zip" + is_csv = data.content_type == "text/csv" + if not (is_zip or is_csv): + return { + "ok": False, + "message": "Invalid file type.", + "code": "invalid_file_type", + } + content = await data.read() + # Write to a temporary file; then pass it to the ZipABBUManager. + # Be sure to clean up the temporary file when we're done. + with tempfile.NamedTemporaryFile() as temp: + temp.write(content) + temp.flush() + data_manager = DataManager.default() + contact_manager = ( + ZipABBUManager(temp.name) + if is_zip + else GoogleContactExportManager(temp.name) + ) + searcher = ContactContributionSearcher(data_manager) + results = list(searcher.search_and_summarize_contacts(contact_manager)) + return { + "ok": True, + "results": [ + { + "contact": result[0].to_data(), + "summary": result[1].to_data(), + } + for result in results + ], + } -app = Litestar([summarize, frontend, frontend_root]) +app = Litestar([search, frontend, frontend_root]) diff --git a/src/App.tsx b/src/App.tsx index 41ce889..1cae8f0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,19 +1,48 @@ +import React, { useCallback, useState } from "react"; import "./App.css"; +import type { SearchResponse } from "./api"; +import { search } from "./api"; +import { Chrome } from "./components/Chrome"; +import { Hero } from "./components/Hero"; +import { SearchResultsView } from "./views/SearchResultsView"; +import { UploadView } from "./views/UploadView"; + +const NotStarted: React.FC<{ onGetStarted: () => void }> = ({ + onGetStarted, +}) => ; + +const Started: React.FC<{ + response: SearchResponse | null; + onSubmit: (e: React.FormEvent) => void; +}> = ({ response, onSubmit }) => ( +
+ {response && response.ok ? ( + + ) : ( + + )} +
+); + +const TenAgainstTrump: React.FC = () => { + const [started, setStarted] = useState(false); + const [response, setResponse] = useState(null); + + const onSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + setResponse(await search(formData)); + }, []); -function App() { return ( -
-

TenForTrump

-

Upload your contacts.zip file here:

-
- -
-
+ + {started ? ( + + ) : ( + setStarted(true)} /> + )} + ); -} +}; -export default App; +export default TenAgainstTrump; diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..d9dc88a --- /dev/null +++ b/src/api.ts @@ -0,0 +1,85 @@ +/** A single matched contact. */ +export interface Contact { + first_name: string; + last_name: string; + city: string; + state: string; + phone: string; + npa_id: string; +} + +/** A summary of one or more contributions to a specific committee. */ +export interface CommitteeSummary { + name: string; + party: string; + total_cents: number; + total_fmt: string; + percent: number; +} + +/** A summary of one or more contributions to a specific party. */ +export interface PartySummary { + total_cents: number; + total_fmt: string; + percent: number; +} + +/** A summary of contributions by a single contact. */ +export interface ContributionSummary { + total_cents: number; + total_fmt: string; + committees: Record; + parties: Record; +} + +/** A single search result. */ +export interface SearchResult { + contact: Contact; + summary: ContributionSummary; +} + +/** A successful search response. */ +export interface SuccessSearchResponse { + ok: true; + results: SearchResult[]; +} + +/** An error response. */ +export interface ErrorSearchResponse { + ok: false; + message: string; + code: string; +} + +/** A successful contact response. */ +export type SearchResponse = SuccessSearchResponse | ErrorSearchResponse; + +/** Comparator for two SearchResult instances. */ +export const compareSearchResults = ( + a: SearchResult, + b: SearchResult +): number => a.summary.total_cents - b.summary.total_cents; + +/** Perform a search of a contact list. */ +export const search = async (form: FormData): Promise => { + // assert that there is a 'data' field in the form + const dataField = form.get("data"); + if (!(dataField instanceof File)) { + throw new Error("Invalid form data"); + } + + const response = await fetch("/api/search", { + method: "POST", + body: form, + }); + const data = (await response.json()) as SearchResponse; + if (!data.ok) { + return data; + } + + // sort the results by total contribution amount, from most to least + // TODO: where *should* this go? + const results = [...data.results].sort(compareSearchResults); + results.reverse(); + return { ok: true, results }; +}; diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx new file mode 100644 index 0000000..83dc24e --- /dev/null +++ b/src/components/Avatar.tsx @@ -0,0 +1,30 @@ +import clsx from "clsx"; + +/** Avatar with text interior, usually used for user initials */ +export const TextAvatar: React.FC< + React.PropsWithChildren<{ className?: string }> +> = ({ children, className }) => ( + + + {children} + + +); + +/** Avatar with image. */ +export const ImageAvatar: React.FC<{ + src: string; + alt: string; + className?: string; +}> = ({ src, alt, className }) => ( + {alt} +); diff --git a/src/components/Chrome.tsx b/src/components/Chrome.tsx new file mode 100644 index 0000000..49131dd --- /dev/null +++ b/src/components/Chrome.tsx @@ -0,0 +1,162 @@ +import { Dialog } from "@headlessui/react"; +import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/outline"; +import { useState } from "react"; +import { TextAvatar } from "./Avatar"; + +const navigation: { name: string; href: string }[] = []; + +// const footerNavigation = { +// us: [ +// { name: "About", href: "#" }, +// { name: "Blog", href: "#" }, +// { name: "Press", href: "#" }, +// { name: "Partners", href: "#" }, +// ], +// legal: [ +// { name: "Privacy", href: "#" }, +// { name: "Terms", href: "#" }, +// ], +// }; + +export const Chrome: React.FC = ({ children }) => { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + return ( +
+ {/* Header */} +
+ + +
+ +
+ + Your Company + + + +
+
+
+
+ {navigation.map((item) => ( + + {item.name} + + ))} +
+ +
+
+
+
+
+ +
+ {/* Hero section */} +
+
+
+ ); +}; diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx new file mode 100644 index 0000000..e696bb0 --- /dev/null +++ b/src/components/Hero.tsx @@ -0,0 +1,27 @@ +export const Hero: React.FC<{ onGetStarted: () => void }> = ({ + onGetStarted, +}) => ( + <> +

+ Team up against{" "} + Trump  +

+

+ Can you raise more than $250? Then TxT{" "} + is the most effective way to make sure Trump never sees + the White House again. +

+ + +); diff --git a/src/main.tsx b/src/main.tsx index 3d7150d..cc71108 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,29 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import './index.css' +import React from "react"; +import ReactDOM from "react-dom/client"; +import type { ErrorPayload } from "vite"; +import App from "./App"; +import "./index.css"; -ReactDOM.createRoot(document.getElementById('root')!).render( +ReactDOM.createRoot(document.getElementById("root")!).render( - , -) + +); + +// REGISTER ERROR OVERLAY for vite dev server +const showErrorOverlay = (err: Partial) => { + // must be within function call because that's when the element is defined for sure. + const ErrorOverlay = customElements.get("vite-error-overlay"); + // don't open outside vite environment + if (!ErrorOverlay) { + return; + } + console.log(err); + const overlay = new ErrorOverlay(err); + document.body.appendChild(overlay); +}; + +window.addEventListener("error", (error) => showErrorOverlay(error.error)); +window.addEventListener("unhandledrejection", (error) => + showErrorOverlay(error.reason) +); diff --git a/src/utils/format.test.ts b/src/utils/format.test.ts new file mode 100644 index 0000000..da75fc4 --- /dev/null +++ b/src/utils/format.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { formatPercent, formatUSD, toTitleCase } from "./format"; + +describe("toTitleCase()", () => { + it("converts a string to title case", () => { + expect(toTitleCase("hello world")).toBe("Hello World"); + }); + + it("handles empty strings", () => { + expect(toTitleCase("")).toBe(""); + }); + + it("handles strings all in upercase", () => { + expect(toTitleCase("HELLO WORLD")).toBe("Hello World"); + }); + + it("handles strings with punctuation", () => { + expect(toTitleCase("hello, world!")).toBe("Hello, World!"); + }); +}); + +describe("formatPercent()", () => { + it("formats a number as a percentage", () => { + expect(formatPercent(0.1234)).toBe("12.3%"); + }); + + it("handles fractions", () => { + expect(formatPercent(0.1234, 2)).toBe("12.34%"); + }); +}); + +describe("formatUSD()", () => { + it("formats a number as a dollar amount", () => { + expect(formatUSD(123456)).toBe("$1,235"); + }); + + it("handles fractions", () => { + expect(formatUSD(123456, 2)).toBe("$1,234.56"); + }); +}); diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..9d451b5 --- /dev/null +++ b/src/utils/format.ts @@ -0,0 +1,52 @@ +/** Convert a string to title case. */ +export const toTitleCase = (s: string): string => + s + .toLowerCase() + .replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substring(1)); + +/** Format a number as a percentage. */ +export const formatPercent = (percent: number, places: number = 1): string => + `${(percent * 100).toFixed(places)}%`; + +/** Format a number as a dollar amount. */ +export const formatUSD = (cents: number, fractionDigits: number = 0): string => + // format without cents. + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }).format(cents / 100); + +/** Format a party abbreviation as a full party name. */ +export const formatParty = (party: string): string => { + // see https://www.fec.gov/campaign-finance-data/party-code-descriptions/ + switch (party) { + case "DEM": + return "Democrat"; + case "REP": + return "Republican"; + case "IND": + return "Independent"; + case "OTH": + return "Other"; + case "UNK": + return "Unknown"; + default: + return party; + } +}; + +/** Return a tailwind color class name for a party. */ +export const partyColorClassName = (party: string) => { + switch (party) { + case "DEM": + return "text-blue-800"; + case "REP": + return "text-red-800"; + case "IND": + return "text-cyan-800"; + default: + return "text-gray-800"; + } +}; diff --git a/src/utils/minimalReporter.ts b/src/utils/minimalReporter.ts new file mode 100644 index 0000000..14d3ea1 --- /dev/null +++ b/src/utils/minimalReporter.ts @@ -0,0 +1,11 @@ +import { DotReporter } from "vitest/reporters"; + +// See https://vitest.dev/guide/reporters.html#custom-reporters + +class MinimalReporter extends DotReporter { + async reportSummary() { + // intentional no-op + } +} + +export default MinimalReporter; diff --git a/src/views/SearchResultsView.tsx b/src/views/SearchResultsView.tsx new file mode 100644 index 0000000..b554b37 --- /dev/null +++ b/src/views/SearchResultsView.tsx @@ -0,0 +1,131 @@ +import type { PartySummary, SearchResult, SuccessSearchResponse } from "../api"; + +import clsx from "clsx"; + +import { + formatParty, + formatPercent, + formatUSD, + partyColorClassName, + toTitleCase, +} from "../utils/format"; + +/** + * Create a copy of the `parties` structure. Count all non-UNK parties and get + * the percentage total for each. + * + * Then, take the UNK party and distribute its total with the same percentage + * breakdown as the other parties. + * + * If there is only one party, and it is UNK, leave it be. + * + * TODO: this belongs on the server + */ +const revisePartySummary = ( + parties: Record +): Record => { + const nonUnknownParties = Object.entries(parties).filter( + ([party]) => party !== "UNK" + ); + if (nonUnknownParties.length === 0) { + return parties; + } + if (nonUnknownParties.length === Object.entries(parties).length) { + return parties; + } + const totalUnknown = parties.UNK.total_cents; + const revisedParties: Record = {}; + for (const [party, summary] of nonUnknownParties) { + revisedParties[party] = { + ...summary, + total_cents: summary.total_cents + totalUnknown * summary.percent, + total_fmt: formatUSD( + summary.total_cents + totalUnknown * summary.percent + ), + }; + } + // Fix the percentages + const revisedTotal = Object.values(revisedParties).reduce( + (total, summary) => total + summary.total_cents, + 0 + ); + for (const [party, summary] of Object.entries(revisedParties)) { + revisedParties[party] = { + ...summary, + percent: summary.total_cents / revisedTotal, + }; + } + return revisedParties; +}; + +/** Return the identifier of the party with the largest percentage summary. */ +const largestParty = (parties: Record): string => { + let largest = "UNK"; + let percent = 0; + for (const [party, summary] of Object.entries(parties)) { + if (summary.percent > percent) { + largest = party; + percent = summary.percent; + } + } + return largest; +}; + +const SearchResults: React.FC<{ results: SearchResult[] }> = ({ results }) => ( +
+

Results

+
    + {results.map((result, i) => ( +
  • +

    + {toTitleCase( + `${result.contact.first_name} ${result.contact.last_name}` + )} +

    +

    + {toTitleCase(result.contact.city)}, {result.contact.state} +

    + {/*

    {result.contact.phone}

    */} +

    Total: {result.summary.total_fmt}

    + {/* Produce a party breakdown */} +
      + {Object.entries(revisePartySummary(result.summary.parties)).map( + ([party, partySummary]) => ( +
    • +

      + {formatParty(party)}: {partySummary.total_fmt} ( + {formatPercent(partySummary.percent)}) +

      +
    • + ) + )} +
    + {/*
      + {Object.entries(result.summary.committees).map( + ([committeeId, committee]) => ( +
    • +

      {committee.name}

      +

      + {committee.total_fmt} ({formatPercent(committee.percent)}) +

      +
    • + ) + )} +
    */} +
  • + ))} +
+
+); + +/** Top-level view component for showing successful search results. */ +export const SearchResultsView: React.FC<{ + response: SuccessSearchResponse; +}> = ({ response }) => ; diff --git a/src/views/UploadView.tsx b/src/views/UploadView.tsx new file mode 100644 index 0000000..a40e927 --- /dev/null +++ b/src/views/UploadView.tsx @@ -0,0 +1,27 @@ +import type { ErrorSearchResponse } from "../api"; + +export interface UploadViewProps { + response: ErrorSearchResponse | null; + onSubmit: (e: React.FormEvent) => void; +} + +export const UploadView: React.FC = ({ + onSubmit, + response, +}) => { + return ( +
+
+ + + +
+ {response &&
Error: {response.message}
} +
+ ); +}; diff --git a/tailwind.config.js b/tailwind.config.js index d37737f..c5e2bcf 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,12 +1,13 @@ /** @type {import('tailwindcss').Config} */ export default { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], theme: { + fontFamily: { + sans: ["DM Sans", "sans-serif"], + serif: ["DM Serif Display", "serif"], + mono: ["DM Mono", "monospace"], + }, extend: {}, }, plugins: [], -} - +}; diff --git a/tsconfig.json b/tsconfig.json index a7fc6fb..0acb659 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,6 @@ "noFallthroughCasesInSwitch": true }, "include": ["src"], + "exclude": ["postcss.config.js", "tailwind.config.js"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/vite.config.ts b/vite.config.ts index 5a33944..a328fe2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,16 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], -}) + server: { + port: 2222, + proxy: { + "/api": { + target: "http://localhost:3333", + changeOrigin: true, + }, + }, + }, +});