A full-stack application for uploading, parsing, and tracking blood test results over time, with biomarker trend visualization and automatic abnormal-value flagging against lab reference ranges.
Status: feature-complete and working end-to-end locally. Deployment is the only remaining step.
Register, log in, upload a blood test PDF and have it automatically parsed into structured biomarker data, review and confirm (or correct) what was extracted, log manual entries with one or many biomarkers at once (home blood pressure, glucose, anything without a lab PDF), browse how any biomarker has trended over time with abnormal values clearly flagged against reference ranges, export your full history as a CSV, and delete your account and every associated record permanently if you choose to.
Backend: FastAPI, PostgreSQL, SQLAlchemy + Alembic migrations, JWT auth (bcrypt password hashing), a custom Redis-backed sliding-window-log rate limiter (an extended, pluggable-key version of the standalone redis-rate-limiter project), pdfplumber-based PDF parsing, Docker Compose.
Frontend: React 19 + TypeScript + Vite, Tailwind CSS v4, React Router, Recharts for biomarker trend charts, lucide-react for icons. Built and iterated with Google AI Studio; every API integration verified by hand against the real backend rather than assumed correct.
blood-test-tracker/
├── backend/
│ ├── app/
│ │ ├── api/ # route handlers (auth, pdf, plots)
│ │ ├── core/ # config, security (JWT, bcrypt)
│ │ ├── crud/ # database read/write functions
│ │ ├── limiter/ # rate limiter (dependency + Redis client)
│ │ ├── models/ # SQLAlchemy models
│ │ ├── schemas/ # Pydantic request/response schemas
│ │ ├── services/ # PDF parsing, metadata extraction
│ │ └── main.py
│ ├── alembic/ # migrations
│ ├── docker-compose.yml
│ └── requirements.txt
└── frontend/
├── src/
│ ├── components/ # Layout, BiomarkerChart, ConfirmationModal, ComingSoonModal
│ ├── pages/ # Login, Register, Reports, ReportDetail, Biomarkers, AddManualEntry, UploadPDF, Profile
│ ├── api.ts # all backend API calls
│ ├── types.ts # TypeScript types mirroring the backend's Pydantic schemas
│ └── utils.ts
└── package.json
React (Vite) FastAPI PostgreSQL
| | |
|-- POST /auth/register --------->|-- create_new_user ------------------>|
|-- POST /auth/login ------------>|-- verify + issue JWT ----------------|
|-- DELETE /auth/me ------------->|-- delete user, CASCADE deletes ----->|
| | every report + result |
|-- POST /pdf/upload ------------>|-- parse PDF, save file ------------->|
|-- POST /pdf/validate/{id} ----->|-- confirm parsed data, verify ------>|
|-- PATCH /reports/{id}/edit ---->|-- persist an edit to a report ------>|
|-- POST /pdf/standalone -------->|-- manual entry, 1+ biomarkers ------>|
|-- GET /pdf/reports ------------>|-- list summaries -------------------->|
|-- GET /plots/history/{test} --->|-- one biomarker's time series ------->|
|-- GET /plots/report/{id} ------>|-- full report detail ---------------->|
JWT auth (Authorization: Bearer <token>), token persisted in localStorage. A separate /auth/swagger-login endpoint exists purely so FastAPI's auto-generated Swagger UI has a working "Authorize" button (it requires OAuth2PasswordRequestForm, a different shape than the JSON-body /auth/login the real frontend uses) — both routes share one _authenticate_user helper so their logic can't drift apart.
Every report has a status: unverified → pending → verified.
unverified— a freshly parsed PDF. Regex-based extraction from unstructured text is inherently imperfect, so nothing is trusted until a human confirms it.pending— a report explicitly flagged for later review (one-directional: onlyunverified → pending).verified— either a human confirmed a parsed report viaPOST /pdf/validate/{id}, or the entry was created as a standalone manual entry, which skips the review workflow entirely since there was never an unreliable extraction step to distrust in the first place.
Unverified data is excluded from biomarker trend history and the test-name list at the query level, not just visually flagged — an unconfirmed, possibly-misread number should never silently appear on a health trend chart.
Manual entries reuse the exact same Report → TestResult structure as PDF uploads. No new tables were needed to support multi-biomarker manual entries (e.g. logging a home blood pressure reading as two related values, systolic and diastolic, under one entry with one shared date) — a Report having many TestResult rows is exactly how parsed PDF uploads already worked. The only change needed was the request schema, from a single flat biomarker to a list:
{
"file_name": "Home Blood Pressure",
"collection_date": "2026-07-08",
"lab_name": "Home Device",
"biomarkers": [
{ "test_name": "Systolic", "value": 120, "unit": "mmHg", "lower_bound": 90, "upper_bound": 130 },
{ "test_name": "Diastolic", "value": 80, "unit": "mmHg", "lower_bound": 60, "upper_bound": 85 }
]
}Account deletion needed almost no application logic. DELETE /auth/me deletes the authenticated user's row; every Report and, transitively, every TestResult belonging to them is removed automatically via ON DELETE CASCADE, configured once at the database level. The route itself is a single db.delete(user) call. The frontend requires typing the account's own email into a confirmation modal (ConfirmationModal.tsx) before the delete button is even enabled, given how irreversible this action is.
Two-factor authentication is presented honestly as unavailable, not as a broken control. There is no backend support for 2FA. Rather than a toggle that silently does nothing, the frontend uses a dedicated ComingSoonModal.tsx so the UI never implies a feature works when it doesn't.
get_current_user re-checks the database on every request, not just the JWT signature. A cryptographically valid, unexpired token only proves it was issued by this server — it says nothing about whether that user still exists. Since account deletion is now a real feature, this matters concretely: a deleted account's still-valid old token is correctly rejected because the second lookup finds no matching row.
N+1 queries are avoided in the reports list via a single query using an outer join plus GROUP BY/COUNT, computing every report's test count in the same round-trip that fetches the reports themselves, rather than one extra query per report.
- The rate limiter's two documented algorithmic gaps apply here too (non-atomic operation sequence across its four Redis calls; a rare same-millisecond counting edge case) — both are explained in full in the standalone redis-rate-limiter project. Accepted as-is at this project's scale (protecting login/upload endpoints from abuse), not fixed in either project.
- CSV export is assembled client-side from data already fetched via existing endpoints, not served by a dedicated export endpoint — a deliberate, reasonable choice rather than a shortcut awaiting a fix.
- PDF parsing is regex-based and inherently heuristic — different labs format reports differently, and no fixed pattern can perfectly parse every layout. This is precisely why the review/verification workflow exists as a first-class part of the design rather than an afterthought.
- CORS origins were configured only once the frontend had a real deployed URL — see Deployment below.
- The frontend's
package.jsoncarries a few unused dependencies (@google/genai,express,dotenv,tsx) left over from Google AI Studio's default project scaffold — none are imported or used anywhere insrc/, andGEMINI_API_KEYin.env.exampleis likewise unused; this app makes no calls to Gemini or any LLM. Safe to remove in a future cleanup pass, not required for correctness.
- Custom reference ranges — override default lab bounds per user or per test.
- Bulk upload — multiple PDFs in one action.
- Re-upload / replace a report whose PDF was parsed poorly, without a full delete-and-reupload.
- Email alerts on a newly flagged abnormal result, and reminders for reports left unverified.
- Real two-factor authentication and password/email change endpoints.
git clone https://github.com/SiddhantGupta3112/blood-test-tracker.git
cd blood-test-tracker/backend
cp .env.example .env
docker compose up
# in a second terminal
cd ../frontend
cp .env.example .env # set VITE_API_BASE_URL to the backend's URL, e.g. http://localhost:8000
npm install
npm run dev- Passwords hashed with bcrypt; JWTs signed with HS256 using standard registered claims (
iss,sub,exp,iat). - Login and upload endpoints are rate-limited (5 login attempts / 60s per IP, 10 uploads / 60s per authenticated user).
- Every report/result lookup is scoped to the requesting user's
user_id, preventing cross-account access via ID guessing. - Destructive actions (account deletion) require typed confirmation in the UI in addition to backend auth, not relied on as the only safeguard.