devrup404 commited on
Commit
f4102c2
·
1 Parent(s): 91b9f55

BeatSense: unified FastAPI + Next.js deployment

Browse files

Multi-stage Dockerfile:
- Build Next.js 16 with standalone output
- Runtime serves FastAPI on :8000 + Next.js on :7860
- /api/* rewrites to internal FastAPI

Includes full clinical workstation UI with i18n (EN/ES),
dark mode, and printable thermal-style ticket result.

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +46 -0
  2. .gitattributes +1 -0
  3. Dockerfile +82 -0
  4. README.md +467 -3
  5. api/main.py +269 -0
  6. data/heart.csv +919 -0
  7. docker/entrypoint.sh +42 -0
  8. frontend/.gitignore +41 -0
  9. frontend/README.md +36 -0
  10. frontend/eslint.config.mjs +18 -0
  11. frontend/next.config.ts +16 -0
  12. frontend/package.json +44 -0
  13. frontend/pnpm-lock.yaml +0 -0
  14. frontend/pnpm-workspace.yaml +3 -0
  15. frontend/postcss.config.mjs +7 -0
  16. frontend/public/file.svg +1 -0
  17. frontend/public/globe.svg +1 -0
  18. frontend/public/logu1.png +3 -0
  19. frontend/public/next.svg +1 -0
  20. frontend/public/vercel.svg +1 -0
  21. frontend/public/window.svg +1 -0
  22. frontend/src/app/[lang]/eda/page.tsx +21 -0
  23. frontend/src/app/[lang]/layout.tsx +34 -0
  24. frontend/src/app/[lang]/model/page.tsx +21 -0
  25. frontend/src/app/[lang]/new-evaluation/page.tsx +21 -0
  26. frontend/src/app/[lang]/page.tsx +21 -0
  27. frontend/src/app/[lang]/patients/page.tsx +21 -0
  28. frontend/src/app/[lang]/settings/page.tsx +21 -0
  29. frontend/src/app/favicon.ico +0 -0
  30. frontend/src/app/globals.css +146 -0
  31. frontend/src/app/layout.tsx +37 -0
  32. frontend/src/components/charts/eda-charts.tsx +150 -0
  33. frontend/src/components/dashboard/dashboard-client.tsx +216 -0
  34. frontend/src/components/forms/evaluation-form.tsx +392 -0
  35. frontend/src/components/layout/header.tsx +52 -0
  36. frontend/src/components/layout/language-toggle.tsx +43 -0
  37. frontend/src/components/layout/page-header.tsx +21 -0
  38. frontend/src/components/layout/sidebar.tsx +84 -0
  39. frontend/src/components/layout/theme-toggle.tsx +30 -0
  40. frontend/src/components/model/model-insights.tsx +130 -0
  41. frontend/src/components/patients/patients-table.tsx +205 -0
  42. frontend/src/components/providers.tsx +45 -0
  43. frontend/src/components/settings/settings-panel.tsx +61 -0
  44. frontend/src/components/ticket/ticket.tsx +254 -0
  45. frontend/src/components/ui/badge.tsx +31 -0
  46. frontend/src/components/ui/button.tsx +53 -0
  47. frontend/src/components/ui/card.tsx +73 -0
  48. frontend/src/components/ui/input.tsx +18 -0
  49. frontend/src/components/ui/label.tsx +20 -0
  50. frontend/src/components/ui/select.tsx +81 -0
.dockerignore ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ **/.git
2
+ **/.gitignore
3
+ **/.gitattributes
4
+ **/.DS_Store
5
+
6
+ # Python
7
+ .venv
8
+ __pycache__/
9
+ *.pyc
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+
14
+ # Node
15
+ frontend/node_modules
16
+ frontend/.next
17
+ frontend/out
18
+ frontend/.turbo
19
+
20
+ # Notebooks / reports / huge artefacts not needed at runtime
21
+ notebook/
22
+ report/
23
+ assets/
24
+
25
+ # Local dev junk
26
+ *.log
27
+ .env
28
+ .env.*
29
+ !.env.example
30
+ .streamlit/
31
+ .python-version
32
+
33
+ # Docs
34
+ docs/article-medium.md
35
+ docs/inform.pdf
36
+ docs/historial_pacientes.csv
37
+
38
+ # Legacy
39
+ Dockerfile.streamlit
40
+ docker-compose.yml
41
+ app.py
42
+ .github/
43
+
44
+ # Macros
45
+ .idea/
46
+ .vscode/
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.png filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1.7
2
+ # ---------------------------------------------------------------------------
3
+ # BeatSense — single-container Hugging Face Space image
4
+ # Runs FastAPI on :8000 (internal) and Next.js on :7860 (public, HF Spaces port)
5
+ # Next.js rewrites /api/* -> http://127.0.0.1:8000/*
6
+ # ---------------------------------------------------------------------------
7
+
8
+ # ============================================================================
9
+ # Stage 1: build the Next.js frontend (standalone output)
10
+ # ============================================================================
11
+ FROM node:20-bookworm-slim AS frontend-builder
12
+
13
+ ENV PNPM_HOME=/pnpm
14
+ ENV PATH=$PNPM_HOME:$PATH
15
+ RUN corepack enable && corepack prepare pnpm@10 --activate
16
+
17
+ WORKDIR /app/frontend
18
+
19
+ # Cache deps
20
+ COPY frontend/package.json frontend/pnpm-lock.yaml ./
21
+ RUN pnpm install --frozen-lockfile
22
+
23
+ # Build
24
+ COPY frontend ./
25
+ RUN pnpm build
26
+
27
+
28
+ # ============================================================================
29
+ # Stage 2: runtime — Python + FastAPI + Node runtime + Next.js standalone
30
+ # ============================================================================
31
+ FROM python:3.11-slim-bookworm AS runtime
32
+
33
+ ENV PYTHONDONTWRITEBYTECODE=1 \
34
+ PYTHONUNBUFFERED=1 \
35
+ PIP_NO_CACHE_DIR=1 \
36
+ NODE_ENV=production \
37
+ PORT=7860 \
38
+ HOSTNAME=0.0.0.0 \
39
+ API_INTERNAL_URL=http://127.0.0.1:8000
40
+
41
+ # System deps: Node.js (for Next.js standalone), curl (for healthchecks)
42
+ RUN apt-get update && apt-get install -y --no-install-recommends \
43
+ curl \
44
+ ca-certificates \
45
+ gnupg \
46
+ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
47
+ && apt-get install -y --no-install-recommends nodejs \
48
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
49
+
50
+ # uv for fast Python installs
51
+ COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /usr/local/bin/uv
52
+
53
+ # HF Spaces runs containers as a non-root user with UID 1000
54
+ RUN useradd -m -u 1000 app
55
+ WORKDIR /home/app/code
56
+ RUN chown -R app:app /home/app
57
+
58
+ USER app
59
+ ENV HOME=/home/app
60
+ ENV PATH="/home/app/.local/bin:$PATH"
61
+
62
+ # ---- Python deps ----
63
+ COPY --chown=app:app pyproject.toml uv.lock ./
64
+ RUN uv sync --frozen --no-dev --compile-bytecode
65
+
66
+ # ---- App source ----
67
+ COPY --chown=app:app api ./api
68
+ COPY --chown=app:app models ./models
69
+ COPY --chown=app:app data ./data
70
+ RUN mkdir -p docs
71
+
72
+ # ---- Frontend: Next.js standalone bundle ----
73
+ COPY --from=frontend-builder --chown=app:app /app/frontend/.next/standalone ./frontend
74
+ COPY --from=frontend-builder --chown=app:app /app/frontend/.next/static ./frontend/.next/static
75
+ COPY --from=frontend-builder --chown=app:app /app/frontend/public ./frontend/public
76
+
77
+ # ---- Entrypoint that runs both processes ----
78
+ COPY --chown=app:app docker/entrypoint.sh /home/app/entrypoint.sh
79
+ RUN chmod +x /home/app/entrypoint.sh
80
+
81
+ EXPOSE 7860
82
+ ENTRYPOINT ["/home/app/entrypoint.sh"]
README.md CHANGED
@@ -1,10 +1,474 @@
1
  ---
2
  title: BeatSense
3
- emoji: 🔥
4
  colorFrom: blue
5
- colorTo: gray
6
  sdk: docker
 
7
  pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
  title: BeatSense
3
+ emoji: 🩺
4
  colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ license: mit
10
+ short_description: Cardiovascular risk assessment workstation (FastAPI + Next.js)
11
+ ---
12
+
13
+ <p align="center">
14
+ <img src="src/img/logo.png" alt="BeatSense" width="260" />
15
+ </p>
16
+
17
+ <h1 align="center">BeatSense — Cardiovascular Risk Intelligence</h1>
18
+
19
+ <p align="center">
20
+ <a href="https://huggingface.co/spaces/devrup404/BeatSense">
21
+ <img src="src/img/qr_hf.png" alt="QR — Hugging Face Space" width="160"/>
22
+ </a>
23
+ </p>
24
+
25
+ <p align="center">
26
+ <sub><b>Scan to open the Hugging Face Space</b></sub>
27
+ </p>
28
+
29
+ <p align="center">
30
+ <a href="README.md"><img src="https://img.shields.io/badge/English-0d1a1f?style=flat&logoColor=7fd1c6&labelColor=0a1014" alt="English" /></a>
31
+ ·
32
+ <a href="README.es.md"><img src="https://img.shields.io/badge/Espa%C3%B1ol-0d1a1f?style=flat&logoColor=7fd1c6&labelColor=0a1014" alt="Español" /></a>
33
+ </p>
34
+
35
+ <p align="center">
36
+ <img src="https://img.shields.io/badge/Python-3.11-0a1014?logo=python&logoColor=7fd1c6&labelColor=0d1a1f" />
37
+ <img src="https://img.shields.io/badge/FastAPI-0.115-0a1014?logo=fastapi&logoColor=7fd1c6&labelColor=0d1a1f" />
38
+ <img src="https://img.shields.io/badge/scikit--learn-1.8-0a1014?logo=scikitlearn&logoColor=7fd1c6&labelColor=0d1a1f" />
39
+ <img src="https://img.shields.io/badge/XGBoost-3.x-0a1014?logoColor=7fd1c6&labelColor=0d1a1f" />
40
+ <img src="https://img.shields.io/badge/Optuna-4.x-0a1014?logoColor=7fd1c6&labelColor=0d1a1f" />
41
+ <img src="https://img.shields.io/badge/uv-package%20mgr-0a1014?logoColor=7fd1c6&labelColor=0d1a1f" />
42
+ <img src="https://img.shields.io/badge/Next.js-16-0a1014?logo=nextdotjs&logoColor=7fd1c6&labelColor=0d1a1f" />
43
+ <img src="https://img.shields.io/badge/React-19-0a1014?logo=react&logoColor=7fd1c6&labelColor=0d1a1f" />
44
+ <img src="https://img.shields.io/badge/TypeScript-5-0a1014?logo=typescript&logoColor=7fd1c6&labelColor=0d1a1f" />
45
+ <img src="https://img.shields.io/badge/Tailwind-v4-0a1014?logo=tailwindcss&logoColor=7fd1c6&labelColor=0d1a1f" />
46
+ <img src="https://img.shields.io/badge/TanStack%20Query-5-0a1014?logoColor=7fd1c6&labelColor=0d1a1f" />
47
+ <img src="https://img.shields.io/badge/Recharts-2-0a1014?logoColor=7fd1c6&labelColor=0d1a1f" />
48
+ <img src="https://img.shields.io/badge/pnpm-10-0a1014?logo=pnpm&logoColor=7fd1c6&labelColor=0d1a1f" />
49
+ <img src="https://img.shields.io/badge/Streamlit-1.x-0a1014?logo=streamlit&logoColor=7fd1c6&labelColor=0d1a1f" />
50
+ <img src="https://img.shields.io/badge/Docker-compose-0a1014?logo=docker&logoColor=7fd1c6&labelColor=0d1a1f" />
51
+ </p>
52
+
53
+ <p align="center">
54
+ <a href="https://medium.com/@devrup404/most-clinical-machine-learning-prototypes-share-the-same-fate-2c357e91e112"><img src="https://img.shields.io/badge/Medium-Read%20the%20article-0a1014?logo=medium&logoColor=7fd1c6&labelColor=0d1a1f" alt="Read on Medium" /></a>
55
+ </p>
56
+
57
+ > **BeatSense** is a cardiovascular risk assessment platform born from a question: what would a coronary risk tool actually feel like if it were built for the nurses station of a real hospital, instead of for a notebook demo?
58
+ >
59
+ > Around a trained scikit-learn model (918 patients, twelve clinical features), it ships three coordinated surfaces: a FastAPI service that exposes the model and persists every assessment, a Next.js 16 clinical workstation with sidebar navigation, dark mode, English and Spanish, and a result rendered as a printable thermal-style ticket ready to clip to the patient record, plus a legacy Streamlit dashboard kept alive for quick demos. The model itself is a Logistic Regression with a custom 0.4 decision threshold, picked because in cardiology a false negative is far more dangerous than a false positive, an interpretable choice that recovers 94% of true positives.
60
+ >
61
+ > The repo is end-to-end: notebooks for EDA and training, a typed REST API, a production-grade clinical UI, internationalization with zero hardcoded strings, and theme tokens out of the box.
62
+
63
+ ---
64
+
65
+ ## Table of Contents
66
+
67
+ | | |
68
+ |---|---|
69
+ | Overview | Live Demo |
70
+ | Features | Architecture |
71
+ | Project Structure | Tech Stack |
72
+ | Dataset | Model Selection |
73
+ | Getting Started | Make Targets |
74
+ | API Reference | Frontend Highlights |
75
+ | Internationalization | Theming |
76
+ | Testing | Docker |
77
+
78
+ ---
79
+
80
+ ## Overview
81
+
82
+ **BeatSense** predicts the probability of coronary heart disease from clinical patient data. It ships three independent surfaces sharing the same trained model:
83
+
84
+ - A **FastAPI** REST backend that wraps the production model (models/modelo_coronario.pkl).
85
+ - A **Next.js 16** clinical workstation UI — sidebar, ticket-style report, dark mode, EN/ES — for nurses and clinicians.
86
+ - A legacy **Streamlit** dashboard (still operational) for quick demos.
87
+
88
+ The trained model is a **Logistic Regression** with a custom decision threshold of 0.4, chosen because in cardiology the cost of a false negative (missing a sick patient) far exceeds that of a false positive.
89
+
90
+ ---
91
+
92
+ ## Live Demo
93
+
94
+ <p>
95
+ <a href="https://huggingface.co/spaces/devrup404/BeatSense"><img src="https://img.shields.io/badge/Hugging%20Face-Open%20Space-0a1014?style=flat&logo=huggingface&logoColor=7fd1c6&labelColor=0d1a1f" alt="Hugging Face Space" /></a>
96
+ </p>
97
+
98
+ ---
99
+
100
+ ## Features
101
+
102
+ | Surface | Highlights |
103
+ |---|---|
104
+ | 🤖 **ML pipeline** | EDA + survival analysis (Kaplan–Meier, Cox), preprocessing, 4 models compared, Optuna tuning, full notebooks. |
105
+ | 🚀 **REST API** | FastAPI service with /predict, /history, /stats, /eda, /model-info, persistent CSV history, CORS enabled. |
106
+ | 🩺 **Clinical UI** | Next.js 16 + Tailwind v4 + shadcn-style components. Sidebar navigation, nurse-station header, KPI dashboard, filtered patient table, EDA charts, model insights. |
107
+ | 🖨️ **Printable ticket** | Risk result rendered as a thermal-printer ticket: monospace font, dashed dividers, barcode, window.print(). |
108
+ | 🌍 **i18n** | English (default) + Spanish, zero hardcoded strings, JSON dictionaries, cookie + Accept-Language detection. |
109
+ | 🌓 **Dark mode** | First-class next-themes integration, dark by default, no flash on first paint. |
110
+ | 📦 **Containerized** | Docker / docker-compose for the Streamlit surface. |
111
+ | 🛠️ **Dual toolchain** | uv for Python, pnpm for the Next.js workspace. One make dev boots both servers. |
112
+
113
+ ---
114
+
115
+ ## Architecture
116
+
117
+ ```
118
+ ┌─────────────────────────┐ HTTPS/REST ┌────────────────────────┐
119
+ │ Next.js 16 frontend │ ────────────────▶ │ FastAPI service │
120
+ │ • App Router │ │ • /predict │
121
+ │ • Server + Client │ │ • /history /stats │
122
+ │ Components │ │ • /eda /model-info │
123
+ │ • TanStack Query │ ◀──────────────── │ • Loads .pkl + scaler │
124
+ └─────────────┬───────────┘ JSON └───────────┬────────────┘
125
+ │ │
126
+ │ static assets │ joblib.load
127
+ ▼ ▼
128
+ ┌──────────┐ ┌─────────────────────┐
129
+ │ /public │ │ models/ │
130
+ │ logu1.png│ │ • modelo_coronario │
131
+ └──────────┘ │ • scaler_coronario │
132
+ └─────────────────────┘
133
+
134
+ │ persists
135
+
136
+ ┌─────────┴───────────┐
137
+ │ docs/ │
138
+ │ historial_pacientes │
139
+ └──────────────────────┘
140
+ ```
141
+
142
+ ---
143
+
144
+ ## Project Structure
145
+
146
+ ```
147
+ BeatSense/
148
+ ├── api/ # FastAPI backend
149
+ │ └── main.py # /predict, /history, /stats, /eda, /model-info
150
+
151
+ ├── app.py # Legacy Streamlit app (still works)
152
+
153
+ ├── frontend/ # Next.js 16 clinical workstation
154
+ │ ├── public/
155
+ │ │ └── logu1.png # Logo (auto-cropped for tight framing)
156
+ │ ├── src/
157
+ │ │ ├── app/
158
+ │ │ │ ├── layout.tsx # Root layout — ThemeProvider lives here
159
+ │ │ │ ├── globals.css # Tailwind v4 + design tokens + print CSS
160
+ │ │ │ └── [lang]/ # Locale-scoped routes
161
+ │ │ │ ├── layout.tsx # CRM shell (Sidebar + Header) + Providers
162
+ │ │ │ ├── page.tsx # Dashboard (KPIs + recent + pie chart)
163
+ │ │ │ ├── new-evaluation/ # Clinical form → ticket result
164
+ │ │ │ ├── patients/ # Filterable history table + CSV export
165
+ │ │ │ ├── eda/ # Dataset analytics (Recharts)
166
+ │ │ │ ├── model/ # Model metrics + comparison
167
+ │ │ │ └── settings/ # Language + theme switcher
168
+ │ │ ├── components/
169
+ │ │ │ ├── ui/ # Button, Card, Input, Label, Select, Badge
170
+ │ │ │ ├── layout/ # Sidebar, Header, PageHeader, toggles
171
+ │ │ │ ├── forms/ # EvaluationForm (RHF + Zod)
172
+ │ │ │ ├── ticket/ # Printable result ticket
173
+ │ │ │ ├── charts/ # EDA charts
174
+ │ │ │ ├── dashboard/ # Dashboard client widgets
175
+ │ │ │ ├── patients/ # Patient table
176
+ │ │ │ ├── model/ # Model insights
177
+ │ │ │ ├── settings/ # Settings panel
178
+ │ │ │ └── providers.tsx # ThemeRoot + I18nProvider + QueryClient
179
+ │ │ ├── i18n/
180
+ │ │ │ ├── config.ts # Locale list + defaultLocale
181
+ │ │ │ ├── context.tsx # Client-side useT() hook
182
+ │ │ │ ├── dictionaries.ts # Server-only loader
183
+ │ │ │ └── dictionaries/
184
+ │ │ │ ├── en.json
185
+ │ │ │ └── es.json
186
+ │ │ ├── lib/
187
+ │ │ │ ├── api.ts # Typed REST client (TanStack-friendly)
188
+ │ │ │ └── utils.ts # cn() helper
189
+ │ │ └── proxy.ts # Next 16 middleware: locale routing
190
+ │ ├── package.json
191
+ │ └── tsconfig.json
192
+
193
+ ├── data/ # Heart Disease dataset (918 records)
194
+ │ ├── heart.csv # Raw
195
+ │ ├── heart_procesado_g.csv # Processed by Gema
196
+ │ ├── heart_procesado_i.csv # Processed by Isrodam
197
+ │ └── heart_processed_R.csv # Processed by Roberto
198
+
199
+ ├── models/ # Persisted scikit-learn artifacts
200
+ │ ├── modelo_coronario.pkl
201
+ │ ├── scaler_coronario.pkl
202
+ │ └── model_R.pkl
203
+
204
+ ├── notebook/ # EDA + training notebooks
205
+ │ ├── eda_heart_disease_p.ipynb # EDA + survival analysis (Paloma)
206
+ │ ├── eda_heart_failure_g.ipynb # EDA (Gema)
207
+ │ ├── eda_heart_failure_i.ipynb # EDA (Isrodam)
208
+ │ ├── eda_heart_failure_R.ipynb # EDA (Roberto)
209
+ │ ├── entrenamiento_modelo_final.ipynb
210
+ │ ├── entrenamiento_modelo_g.ipynb
211
+ │ └── entrenamiento_modelo_i.ipynb
212
+
213
+ ├── report/ # Evaluation reports (PDF)
214
+ ├── docs/historial_pacientes.csv # Patient history (written by /predict)
215
+ ├── src/img/qr_app.png # QR code to live demo
216
+ ├── assets/ # Static assets (lottie, etc.)
217
+ ├── .streamlit/ # Streamlit theming
218
+ ├── Makefile # uv + pnpm orchestration
219
+ ├── dockerfile
220
+ ├── docker-compose.yml
221
+ ├── pyproject.toml # uv-managed Python deps
222
+ └── uv.lock
223
+ ```
224
+
225
+ ---
226
+
227
+ ## Tech Stack
228
+
229
+ ### Backend / ML
230
+
231
+ - **Python 3.11** managed with **uv**
232
+ - **FastAPI** + **Uvicorn** — REST API
233
+ - **scikit-learn** — Logistic Regression, RF, KNN; StandardScaler
234
+ - **XGBoost** — alternative model evaluated
235
+ - **Optuna** — hyperparameter search
236
+ - **pandas / numpy** — data wrangling
237
+ - **joblib** — model persistence
238
+ - **Streamlit** — legacy interactive dashboard
239
+ - **pytest** — tests
240
+
241
+ ### Frontend
242
+
243
+ - **Next.js 16** (App Router, Turbopack, Server Components)
244
+ - **React 19** + **TypeScript 5**
245
+ - **Tailwind CSS v4** with custom design tokens
246
+ - **shadcn-style UI** built on **Radix UI** primitives
247
+ - **TanStack Query 5** — server-state caching
248
+ - **React Hook Form 7** + **Zod 4** — typed forms with runtime validation
249
+ - **Recharts** — analytics charts
250
+ - **next-themes** — dark/light theme
251
+ - **next-intl-style i18n** using Next.js native dictionaries
252
+ - **lucide-react** — icon set
253
+ - **pnpm 10** — workspace package manager
254
+
255
+ ### DevOps
256
+
257
+ - **Make** — single command interface
258
+ - **Docker / docker-compose** — containerised Streamlit deployment
259
+ - **GitHub Actions** — CI/CD
260
+ - **Render** — production hosting (Streamlit demo)
261
+
262
+ ---
263
+
264
+ ## Dataset
265
+
266
+ 918 records, 12 features:
267
+
268
+ | Variable | Description | Type |
269
+ |---|---|---|
270
+ | Age | Patient age | Numeric |
271
+ | Sex | Sex (M/F) | Categorical |
272
+ | ChestPainType | Type (ATA, NAP, ASY, TA) | Categorical |
273
+ | RestingBP | Resting blood pressure (mm Hg) | Numeric |
274
+ | Cholesterol | Serum cholesterol (mg/dl) | Numeric |
275
+ | FastingBS | Fasting blood sugar > 120 mg/dl (0/1) | Binary |
276
+ | RestingECG | Resting ECG result | Categorical |
277
+ | MaxHR | Max heart rate achieved | Numeric |
278
+ | ExerciseAngina | Exercise-induced angina (Y/N) | Binary |
279
+ | Oldpeak | ST depression | Numeric |
280
+ | ST_Slope | ST segment slope | Categorical |
281
+ | HeartDisease | **Target** — heart disease (0/1) | Binary |
282
+
283
+ The production model uses **12 engineered features** (Age, Sex, MaxHR, FastingBS, ExerciseAngina, one-hot of ChestPainType, one-hot of ST_Slope). Only Age and MaxHR are scaled.
284
+
285
+ ---
286
+
287
+ ## Model Selection
288
+
289
+ | Model | Accuracy | Recall (class 1) | ROC-AUC | Notes |
290
+ |---|---|---|---|---|
291
+ | **Logistic Regression** *(threshold 0.4)* | **88 %** | **0.94** | **0.926** | ✅ Selected |
292
+ | Random Forest | ~87 % | ~0.88 | ~0.91 | Higher raw accuracy, lower recall |
293
+ | K-NN | ~82 % | ~0.83 | ~0.87 | Scale-sensitive |
294
+ | XGBoost | ~86 % | ~0.87 | ~0.90 | Strong, less interpretable |
295
+
296
+ ### Why Logistic Regression?
297
+
298
+ 1. **Highest recall (0.94)** — at threshold 0.4 it catches 94% of truly sick patients. A false negative in cardiology is far worse than a false positive.
299
+ 2. **No overfitting** — train/test gap of only 0.007. 5-fold CV: 84.74% ± 2.91%.
300
+ 3. **Clinical transparency** — linear coefficients let clinicians see exactly which features drive each prediction.
301
+
302
+ > Full analysis:
303
+ >
304
+ > <a href="notebook/entrenamiento_modelo_final.ipynb"><img src="https://img.shields.io/badge/notebook-entrenamiento__modelo__final.ipynb-0a1014?style=flat&logo=jupyter&logoColor=7fd1c6&labelColor=0d1a1f" alt="Notebook" /></a>
305
+ > <a href="report/entrenamiento_modelo_final.pdf"><img src="https://img.shields.io/badge/report-entrenamiento__modelo__final.pdf-0a1014?style=flat&logo=adobeacrobatreader&logoColor=7fd1c6&labelColor=0d1a1f" alt="Report" /></a>
306
+
307
+ ---
308
+
309
+ ## Getting Started
310
+
311
+ ### Prerequisites
312
+
313
+ - Python >=3.11, <3.13
314
+ - <a href="https://docs.astral.sh/uv/"><img src="https://img.shields.io/badge/uv-Python%20toolchain-0a1014?style=flat&logoColor=7fd1c6&labelColor=0d1a1f" alt="uv" /></a>
315
+ - <a href="https://pnpm.io/"><img src="https://img.shields.io/badge/pnpm-%E2%89%A510%20Node.js%20toolchain-0a1014?style=flat&logo=pnpm&logoColor=7fd1c6&labelColor=0d1a1f" alt="pnpm" /></a>
316
+ - Node.js >=20
317
+
318
+ ### Install
319
+
320
+ ```bash
321
+ # Python deps
322
+ make install
323
+
324
+ # Frontend deps
325
+ make frontend-install
326
+ ```
327
+
328
+ ### Run everything (recommended)
329
+
330
+ ```bash
331
+ make dev
332
+ ```
333
+
334
+ This spawns:
335
+
336
+ <p>
337
+ <a href="http://localhost:8000/docs"><img src="https://img.shields.io/badge/FastAPI-localhost:8000/docs-0a1014?style=flat&logo=fastapi&logoColor=7fd1c6&labelColor=0d1a1f" alt="FastAPI" /></a>
338
+ <a href="http://localhost:3000"><img src="https://img.shields.io/badge/Next.js-localhost:3000-0a1014?style=flat&logo=nextdotjs&logoColor=7fd1c6&labelColor=0d1a1f" alt="Next.js" /></a>
339
+ </p>
340
+
341
+ ### Run individually
342
+
343
+ ```bash
344
+ make api # FastAPI only
345
+ make frontend # Next.js only
346
+ make streamlit # Legacy Streamlit dashboard
347
+ ```
348
+
349
+ ---
350
+
351
+ ## Make Targets
352
+
353
+ | Target | Purpose |
354
+ |---|---|
355
+ | make install | uv sync — install Python deps |
356
+ | make frontend-install | pnpm install — install frontend deps |
357
+ | make dev | Run **both** FastAPI + Next.js in parallel |
358
+ | make api | Run FastAPI only (uvicorn :8000) |
359
+ | make frontend | Run Next.js only (pnpm dev :3000) |
360
+ | make frontend-build | Production build of Next.js |
361
+ | make streamlit | Run legacy Streamlit app |
362
+ | make docker-up / docker-down / docker-build / docker-logs | Docker Compose lifecycle |
363
+ | make jupyter | Launch JupyterLab |
364
+ | make test | Run pytest |
365
+ | make clean | Remove caches and .next/ |
366
+
367
+ ---
368
+
369
+ ## API Reference
370
+
371
+ <p>
372
+ <b>Base URL</b>&nbsp;
373
+ <a href="http://localhost:8000"><img src="https://img.shields.io/badge/localhost:8000-0a1014?style=flat&logoColor=7fd1c6&labelColor=0d1a1f" alt="localhost:8000" /></a>
374
+ </p>
375
+
376
+ | Method | Path | Description |
377
+ |---|---|---|
378
+ | GET | /health | Liveness + model load status |
379
+ | POST | /predict | Run cardiovascular risk prediction |
380
+ | GET | /history | Return all saved patient assessments |
381
+ | GET | /stats | Aggregate KPIs (totals, high-risk %, today) |
382
+ | GET | /eda | Aggregated EDA payload for charts |
383
+ | GET | /model-info | Metrics of the selected model + alternatives |
384
+
385
+ ### POST /predict payload
386
+
387
+ ```json
388
+ {
389
+ "patient_id": "P-001",
390
+ "age": 55,
391
+ "sex": "M",
392
+ "chest_pain_type": "ASY",
393
+ "max_hr": 130,
394
+ "fasting_bs": 0,
395
+ "exercise_angina": "Y",
396
+ "st_slope": "Flat",
397
+ "resting_bp": 140,
398
+ "cholesterol": 240,
399
+ "resting_ecg": "Normal",
400
+ "oldpeak": 1.2
401
+ }
402
+ ```
403
+
404
+ Response:
405
+
406
+ ```json
407
+ {
408
+ "patient_id": "P-001",
409
+ "timestamp": "2026-06-08T10:32:41",
410
+ "probability": 94.28,
411
+ "classification": "HIGH",
412
+ "threshold": 40.0,
413
+ "probabilities": [0.057, 0.943]
414
+ }
415
+ ```
416
+
417
+ ---
418
+
419
+ ## Frontend Highlights
420
+
421
+ - **/[lang]** Clinical dashboard — KPI cards, recent assessments, risk distribution pie.
422
+ - **/[lang]/new-evaluation** Clinical form in three sections (Identification, Vitals, Symptoms/ECG) with RHF + Zod validation. On submit, the answer renders as a printable **ticket** (window.print() switches the print stylesheet so only the ticket prints, like a thermal receipt).
423
+ - **/[lang]/patients** Searchable, filterable history table (high / low / all). CSV export client-side.
424
+ - **/[lang]/eda** Charts: age histogram, sex distribution, chest pain distribution, scatter Max HR vs Age coloured by outcome.
425
+ - **/[lang]/model** Selected model card, comparison bar chart, rationale paragraph.
426
+ - **/[lang]/settings** Language + theme switcher.
427
+
428
+ The layout is a **CRM/EHR shell** with a sticky dark sidebar (logo + navigation + institution badge), a header bar with patient search, shift indicator, language switch, theme toggle and a faux nurse-station avatar.
429
+
430
+ ---
431
+
432
+ ## Internationalization
433
+
434
+ - Built on **Next.js 16 native dictionaries** (app/[lang]/...).
435
+ - All UI strings live in frontend/src/i18n/dictionaries/{en,es}.json.
436
+ - Locale is resolved in this order by frontend/src/proxy.ts:
437
+ 1. locale cookie (set by the language switcher),
438
+ 2. Accept-Language header,
439
+ 3. fallback to **English**.
440
+ - useT() exposes the dictionary to client components.
441
+
442
+ Add a new locale by creating {xx}.json, adding xx to locales in src/i18n/config.ts, and registering it in src/i18n/dictionaries.ts.
443
+
444
+ ---
445
+
446
+ ## Theming
447
+
448
+ - Dark mode is the **default**.
449
+ - Theme tokens are defined as CSS variables in globals.css (:root for light, .dark for dark) and bridged to Tailwind v4 via @theme inline.
450
+ - ThemeProvider is mounted at the **root layout** to avoid hydration issues with next-themes.
451
+
452
+ ---
453
+
454
+ ## Testing
455
+
456
+ ```bash
457
+ make test # pytest
458
+ cd frontend && pnpm build # type-check + production build
459
+ ```
460
+
461
+ ---
462
+
463
+ ## Docker
464
+
465
+ ```bash
466
+ make docker-up # Build and run Streamlit container
467
+ make docker-logs # Stream logs
468
+ make docker-down # Stop
469
+ ```
470
+
471
+ > Containerising the Next.js + FastAPI duo is on the roadmap.
472
+
473
  ---
474
 
 
api/main.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import uuid
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ import joblib
10
+ import pandas as pd
11
+ from fastapi import FastAPI, HTTPException
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from pydantic import BaseModel, Field
14
+
15
+ ROOT = Path(__file__).resolve().parent.parent
16
+ MODEL_PATH = ROOT / "models" / "modelo_coronario.pkl"
17
+ SCALER_PATH = ROOT / "models" / "scaler_coronario.pkl"
18
+ HISTORY_PATH = ROOT / "docs" / "historial_pacientes.csv"
19
+ HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
20
+
21
+ HISTORY_COLUMNS = [
22
+ "Fecha", "Hora", "ID_Paciente", "Edad", "Sexo",
23
+ "FC_Maxima", "Glucosa_Ayunas", "Angina_Ejercicio",
24
+ "Tipo_Dolor", "Pendiente_ST",
25
+ "Probabilidad_%", "Clasificacion", "Riesgo",
26
+ ]
27
+
28
+ THRESHOLD = 40.0
29
+
30
+ model = joblib.load(MODEL_PATH)
31
+ scaler = joblib.load(SCALER_PATH)
32
+
33
+
34
+ class PredictRequest(BaseModel):
35
+ patient_id: str | None = Field(default=None, max_length=64)
36
+ age: int = Field(ge=1, le=120)
37
+ sex: Literal["M", "F"]
38
+ chest_pain_type: Literal["ASY", "ATA", "NAP", "TA"]
39
+ max_hr: int = Field(ge=40, le=240)
40
+ fasting_bs: Literal[0, 1]
41
+ exercise_angina: Literal["Y", "N"]
42
+ st_slope: Literal["Up", "Flat", "Down"]
43
+ resting_bp: int | None = Field(default=None, ge=60, le=250)
44
+ cholesterol: int | None = Field(default=None, ge=0, le=700)
45
+ resting_ecg: Literal["Normal", "ST", "LVH"] | None = None
46
+ oldpeak: float | None = Field(default=None, ge=-5, le=10)
47
+
48
+
49
+ class PredictResponse(BaseModel):
50
+ patient_id: str
51
+ timestamp: str
52
+ probability: float
53
+ classification: Literal["HIGH", "LOW"]
54
+ threshold: float
55
+ probabilities: list[float]
56
+
57
+
58
+ class HistoryEntry(BaseModel):
59
+ date: str
60
+ time: str
61
+ patient_id: str
62
+ age: int
63
+ sex: str
64
+ max_hr: int
65
+ fasting_bs: str
66
+ exercise_angina: str
67
+ chest_pain_type: str
68
+ st_slope: str
69
+ probability: float
70
+ classification: str
71
+ risk: str
72
+
73
+
74
+ def build_features(req: PredictRequest) -> pd.DataFrame:
75
+ sex_num = 1 if req.sex == "M" else 0
76
+ exercise_bin = 1 if req.exercise_angina == "Y" else 0
77
+
78
+ cp_asy = int(req.chest_pain_type == "ASY")
79
+ cp_ata = int(req.chest_pain_type == "ATA")
80
+ cp_nap = int(req.chest_pain_type == "NAP")
81
+ cp_ta = int(req.chest_pain_type == "TA")
82
+
83
+ st_flat = int(req.st_slope == "Flat")
84
+ st_up = int(req.st_slope == "Up")
85
+ st_down = int(req.st_slope == "Down")
86
+
87
+ X = pd.DataFrame(
88
+ [[
89
+ req.age, sex_num, req.max_hr, req.fasting_bs,
90
+ exercise_bin, cp_asy, cp_ata, cp_nap, cp_ta,
91
+ st_flat, st_up, st_down,
92
+ ]],
93
+ columns=[
94
+ "Age", "Sex", "MaxHR", "FastingBS", "ExerciseAngina",
95
+ "CP_ASY", "CP_ATA", "CP_NAP", "CP_TA",
96
+ "ST_Slope_Flat", "ST_Slope_Up", "ST_Slope_Down",
97
+ ],
98
+ )
99
+ X[["Age", "MaxHR"]] = scaler.transform(X[["Age", "MaxHR"]])
100
+ return X
101
+
102
+
103
+ def append_history(req: PredictRequest, probability: float, classification: str, patient_id: str) -> None:
104
+ now = datetime.now()
105
+ row = {
106
+ "Fecha": now.strftime("%Y-%m-%d"),
107
+ "Hora": now.strftime("%H:%M:%S"),
108
+ "ID_Paciente": patient_id,
109
+ "Edad": req.age,
110
+ "Sexo": req.sex,
111
+ "FC_Maxima": req.max_hr,
112
+ "Glucosa_Ayunas": "Sí" if req.fasting_bs == 1 else "No",
113
+ "Angina_Ejercicio": "Sí" if req.exercise_angina == "Y" else "No",
114
+ "Tipo_Dolor": req.chest_pain_type,
115
+ "Pendiente_ST": req.st_slope,
116
+ "Probabilidad_%": round(probability, 2),
117
+ "Clasificacion": "Alto Riesgo" if classification == "HIGH" else "Bajo Riesgo",
118
+ "Riesgo": "ALTO" if classification == "HIGH" else "BAJO",
119
+ }
120
+ file_exists = HISTORY_PATH.exists()
121
+ with HISTORY_PATH.open("a", newline="", encoding="utf-8") as f:
122
+ writer = csv.DictWriter(f, fieldnames=HISTORY_COLUMNS)
123
+ if not file_exists or HISTORY_PATH.stat().st_size == 0:
124
+ writer.writeheader()
125
+ writer.writerow(row)
126
+
127
+
128
+ app = FastAPI(title="BeatSense API", version="1.0.0")
129
+ app.add_middleware(
130
+ CORSMiddleware,
131
+ allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
132
+ allow_credentials=True,
133
+ allow_methods=["*"],
134
+ allow_headers=["*"],
135
+ )
136
+
137
+
138
+ @app.get("/health")
139
+ def health() -> dict:
140
+ return {"status": "ok", "model_loaded": True, "threshold": THRESHOLD}
141
+
142
+
143
+ @app.post("/predict", response_model=PredictResponse)
144
+ def predict(req: PredictRequest) -> PredictResponse:
145
+ try:
146
+ X = build_features(req)
147
+ probs = model.predict_proba(X)[0]
148
+ probability = float(probs[1] * 100)
149
+ classification = "HIGH" if probability >= THRESHOLD else "LOW"
150
+ patient_id = req.patient_id or f"P-{uuid.uuid4().hex[:8].upper()}"
151
+ timestamp = datetime.now().isoformat()
152
+ append_history(req, probability, classification, patient_id)
153
+ return PredictResponse(
154
+ patient_id=patient_id,
155
+ timestamp=timestamp,
156
+ probability=probability,
157
+ classification=classification,
158
+ threshold=THRESHOLD,
159
+ probabilities=[float(probs[0]), float(probs[1])],
160
+ )
161
+ except Exception as exc:
162
+ raise HTTPException(status_code=500, detail=str(exc))
163
+
164
+
165
+ @app.get("/history", response_model=list[HistoryEntry])
166
+ def history() -> list[HistoryEntry]:
167
+ if not HISTORY_PATH.exists():
168
+ return []
169
+ df = pd.read_csv(HISTORY_PATH)
170
+ out: list[HistoryEntry] = []
171
+ for _, row in df.iterrows():
172
+ out.append(HistoryEntry(
173
+ date=str(row["Fecha"]),
174
+ time=str(row["Hora"]),
175
+ patient_id=str(row["ID_Paciente"]),
176
+ age=int(row["Edad"]),
177
+ sex=str(row["Sexo"]),
178
+ max_hr=int(row["FC_Maxima"]),
179
+ fasting_bs=str(row["Glucosa_Ayunas"]),
180
+ exercise_angina=str(row["Angina_Ejercicio"]),
181
+ chest_pain_type=str(row["Tipo_Dolor"]),
182
+ st_slope=str(row["Pendiente_ST"]),
183
+ probability=float(row["Probabilidad_%"]),
184
+ classification=str(row["Clasificacion"]),
185
+ risk=str(row["Riesgo"]),
186
+ ))
187
+ return out
188
+
189
+
190
+ @app.get("/stats")
191
+ def stats() -> dict:
192
+ if not HISTORY_PATH.exists():
193
+ return {"total": 0, "high_risk": 0, "low_risk": 0, "high_risk_pct": 0, "today": 0}
194
+ df = pd.read_csv(HISTORY_PATH)
195
+ total = len(df)
196
+ high = int((df["Riesgo"] == "ALTO").sum())
197
+ low = total - high
198
+ today = datetime.now().strftime("%Y-%m-%d")
199
+ today_count = int((df["Fecha"] == today).sum())
200
+ return {
201
+ "total": total,
202
+ "high_risk": high,
203
+ "low_risk": low,
204
+ "high_risk_pct": round(100 * high / total, 1) if total else 0,
205
+ "today": today_count,
206
+ }
207
+
208
+
209
+ @app.get("/eda")
210
+ def eda() -> dict:
211
+ csv_path = ROOT / "data" / "heart.csv"
212
+ if not csv_path.exists():
213
+ return {}
214
+ df = pd.read_csv(csv_path)
215
+ # Age histogram (bins of 5)
216
+ age_bins = list(range(20, 90, 5))
217
+ df["age_bin"] = pd.cut(df["Age"], bins=age_bins, right=False)
218
+ age_hist = (
219
+ df.groupby("age_bin", observed=True)["HeartDisease"]
220
+ .agg(["count", "mean"])
221
+ .reset_index()
222
+ )
223
+ age_dist = [
224
+ {"bin": f"{int(row['age_bin'].left)}-{int(row['age_bin'].right)}", "count": int(row["count"]), "risk_rate": round(float(row["mean"]) * 100, 1)}
225
+ for _, row in age_hist.iterrows()
226
+ ]
227
+ sex_dist = [
228
+ {"sex": s, "count": int((df["Sex"] == s).sum()), "risk_rate": round(float(df[df["Sex"] == s]["HeartDisease"].mean()) * 100, 1)}
229
+ for s in ["M", "F"]
230
+ ]
231
+ cp_dist = [
232
+ {"type": c, "count": int((df["ChestPainType"] == c).sum()), "risk_rate": round(float(df[df["ChestPainType"] == c]["HeartDisease"].mean()) * 100, 1)}
233
+ for c in ["ASY", "ATA", "NAP", "TA"]
234
+ ]
235
+ scatter = (
236
+ df.sample(min(300, len(df)), random_state=42)[["Age", "MaxHR", "HeartDisease"]]
237
+ .to_dict(orient="records")
238
+ )
239
+ return {
240
+ "age_dist": age_dist,
241
+ "sex_dist": sex_dist,
242
+ "chest_pain_dist": cp_dist,
243
+ "scatter": scatter,
244
+ "total": int(len(df)),
245
+ }
246
+
247
+
248
+ @app.get("/model-info")
249
+ def model_info() -> dict:
250
+ return {
251
+ "name": "Logistic Regression",
252
+ "threshold": THRESHOLD,
253
+ "metrics": {
254
+ "accuracy": 0.88,
255
+ "recall": 0.94,
256
+ "roc_auc": 0.926,
257
+ "cv_mean": 0.8474,
258
+ "cv_std": 0.0291,
259
+ },
260
+ "alternatives": [
261
+ {"name": "Random Forest", "accuracy": 0.87, "recall": 0.88, "roc_auc": 0.91},
262
+ {"name": "K-NN", "accuracy": 0.82, "recall": 0.83, "roc_auc": 0.87},
263
+ {"name": "XGBoost", "accuracy": 0.86, "recall": 0.87, "roc_auc": 0.90},
264
+ ],
265
+ "features": [
266
+ "Age", "Sex", "MaxHR", "FastingBS", "ExerciseAngina",
267
+ "ChestPainType (ASY/ATA/NAP/TA)", "ST_Slope (Up/Flat/Down)",
268
+ ],
269
+ }
data/heart.csv ADDED
@@ -0,0 +1,919 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Age,Sex,ChestPainType,RestingBP,Cholesterol,FastingBS,RestingECG,MaxHR,ExerciseAngina,Oldpeak,ST_Slope,HeartDisease
2
+ 40,M,ATA,140,289,0,Normal,172,N,0,Up,0
3
+ 49,F,NAP,160,180,0,Normal,156,N,1,Flat,1
4
+ 37,M,ATA,130,283,0,ST,98,N,0,Up,0
5
+ 48,F,ASY,138,214,0,Normal,108,Y,1.5,Flat,1
6
+ 54,M,NAP,150,195,0,Normal,122,N,0,Up,0
7
+ 39,M,NAP,120,339,0,Normal,170,N,0,Up,0
8
+ 45,F,ATA,130,237,0,Normal,170,N,0,Up,0
9
+ 54,M,ATA,110,208,0,Normal,142,N,0,Up,0
10
+ 37,M,ASY,140,207,0,Normal,130,Y,1.5,Flat,1
11
+ 48,F,ATA,120,284,0,Normal,120,N,0,Up,0
12
+ 37,F,NAP,130,211,0,Normal,142,N,0,Up,0
13
+ 58,M,ATA,136,164,0,ST,99,Y,2,Flat,1
14
+ 39,M,ATA,120,204,0,Normal,145,N,0,Up,0
15
+ 49,M,ASY,140,234,0,Normal,140,Y,1,Flat,1
16
+ 42,F,NAP,115,211,0,ST,137,N,0,Up,0
17
+ 54,F,ATA,120,273,0,Normal,150,N,1.5,Flat,0
18
+ 38,M,ASY,110,196,0,Normal,166,N,0,Flat,1
19
+ 43,F,ATA,120,201,0,Normal,165,N,0,Up,0
20
+ 60,M,ASY,100,248,0,Normal,125,N,1,Flat,1
21
+ 36,M,ATA,120,267,0,Normal,160,N,3,Flat,1
22
+ 43,F,TA,100,223,0,Normal,142,N,0,Up,0
23
+ 44,M,ATA,120,184,0,Normal,142,N,1,Flat,0
24
+ 49,F,ATA,124,201,0,Normal,164,N,0,Up,0
25
+ 44,M,ATA,150,288,0,Normal,150,Y,3,Flat,1
26
+ 40,M,NAP,130,215,0,Normal,138,N,0,Up,0
27
+ 36,M,NAP,130,209,0,Normal,178,N,0,Up,0
28
+ 53,M,ASY,124,260,0,ST,112,Y,3,Flat,0
29
+ 52,M,ATA,120,284,0,Normal,118,N,0,Up,0
30
+ 53,F,ATA,113,468,0,Normal,127,N,0,Up,0
31
+ 51,M,ATA,125,188,0,Normal,145,N,0,Up,0
32
+ 53,M,NAP,145,518,0,Normal,130,N,0,Flat,1
33
+ 56,M,NAP,130,167,0,Normal,114,N,0,Up,0
34
+ 54,M,ASY,125,224,0,Normal,122,N,2,Flat,1
35
+ 41,M,ASY,130,172,0,ST,130,N,2,Flat,1
36
+ 43,F,ATA,150,186,0,Normal,154,N,0,Up,0
37
+ 32,M,ATA,125,254,0,Normal,155,N,0,Up,0
38
+ 65,M,ASY,140,306,1,Normal,87,Y,1.5,Flat,1
39
+ 41,F,ATA,110,250,0,ST,142,N,0,Up,0
40
+ 48,F,ATA,120,177,1,ST,148,N,0,Up,0
41
+ 48,F,ASY,150,227,0,Normal,130,Y,1,Flat,0
42
+ 54,F,ATA,150,230,0,Normal,130,N,0,Up,0
43
+ 54,F,NAP,130,294,0,ST,100,Y,0,Flat,1
44
+ 35,M,ATA,150,264,0,Normal,168,N,0,Up,0
45
+ 52,M,NAP,140,259,0,ST,170,N,0,Up,0
46
+ 43,M,ASY,120,175,0,Normal,120,Y,1,Flat,1
47
+ 59,M,NAP,130,318,0,Normal,120,Y,1,Flat,0
48
+ 37,M,ASY,120,223,0,Normal,168,N,0,Up,0
49
+ 50,M,ATA,140,216,0,Normal,170,N,0,Up,0
50
+ 36,M,NAP,112,340,0,Normal,184,N,1,Flat,0
51
+ 41,M,ASY,110,289,0,Normal,170,N,0,Flat,1
52
+ 50,M,ASY,130,233,0,Normal,121,Y,2,Flat,1
53
+ 47,F,ASY,120,205,0,Normal,98,Y,2,Flat,1
54
+ 45,M,ATA,140,224,1,Normal,122,N,0,Up,0
55
+ 41,F,ATA,130,245,0,Normal,150,N,0,Up,0
56
+ 52,F,ASY,130,180,0,Normal,140,Y,1.5,Flat,0
57
+ 51,F,ATA,160,194,0,Normal,170,N,0,Up,0
58
+ 31,M,ASY,120,270,0,Normal,153,Y,1.5,Flat,1
59
+ 58,M,NAP,130,213,0,ST,140,N,0,Flat,1
60
+ 54,M,ASY,150,365,0,ST,134,N,1,Up,0
61
+ 52,M,ASY,112,342,0,ST,96,Y,1,Flat,1
62
+ 49,M,ATA,100,253,0,Normal,174,N,0,Up,0
63
+ 43,F,NAP,150,254,0,Normal,175,N,0,Up,0
64
+ 45,M,ASY,140,224,0,Normal,144,N,0,Up,0
65
+ 46,M,ASY,120,277,0,Normal,125,Y,1,Flat,1
66
+ 50,F,ATA,110,202,0,Normal,145,N,0,Up,0
67
+ 37,F,ATA,120,260,0,Normal,130,N,0,Up,0
68
+ 45,F,ASY,132,297,0,Normal,144,N,0,Up,0
69
+ 32,M,ATA,110,225,0,Normal,184,N,0,Up,0
70
+ 52,M,ASY,160,246,0,ST,82,Y,4,Flat,1
71
+ 44,M,ASY,150,412,0,Normal,170,N,0,Up,0
72
+ 57,M,ATA,140,265,0,ST,145,Y,1,Flat,1
73
+ 44,M,ATA,130,215,0,Normal,135,N,0,Up,0
74
+ 52,M,ASY,120,182,0,Normal,150,N,0,Flat,1
75
+ 44,F,ASY,120,218,0,ST,115,N,0,Up,0
76
+ 55,M,ASY,140,268,0,Normal,128,Y,1.5,Flat,1
77
+ 46,M,NAP,150,163,0,Normal,116,N,0,Up,0
78
+ 32,M,ASY,118,529,0,Normal,130,N,0,Flat,1
79
+ 35,F,ASY,140,167,0,Normal,150,N,0,Up,0
80
+ 52,M,ATA,140,100,0,Normal,138,Y,0,Up,0
81
+ 49,M,ASY,130,206,0,Normal,170,N,0,Flat,1
82
+ 55,M,NAP,110,277,0,Normal,160,N,0,Up,0
83
+ 54,M,ATA,120,238,0,Normal,154,N,0,Up,0
84
+ 63,M,ASY,150,223,0,Normal,115,N,0,Flat,1
85
+ 52,M,ATA,160,196,0,Normal,165,N,0,Up,0
86
+ 56,M,ASY,150,213,1,Normal,125,Y,1,Flat,1
87
+ 66,M,ASY,140,139,0,Normal,94,Y,1,Flat,1
88
+ 65,M,ASY,170,263,1,Normal,112,Y,2,Flat,1
89
+ 53,F,ATA,140,216,0,Normal,142,Y,2,Flat,0
90
+ 43,M,TA,120,291,0,ST,155,N,0,Flat,1
91
+ 55,M,ASY,140,229,0,Normal,110,Y,0.5,Flat,0
92
+ 49,F,ATA,110,208,0,Normal,160,N,0,Up,0
93
+ 39,M,ASY,130,307,0,Normal,140,N,0,Up,0
94
+ 52,F,ATA,120,210,0,Normal,148,N,0,Up,0
95
+ 48,M,ASY,160,329,0,Normal,92,Y,1.5,Flat,1
96
+ 39,F,NAP,110,182,0,ST,180,N,0,Up,0
97
+ 58,M,ASY,130,263,0,Normal,140,Y,2,Flat,1
98
+ 43,M,ATA,142,207,0,Normal,138,N,0,Up,0
99
+ 39,M,NAP,160,147,1,Normal,160,N,0,Up,0
100
+ 56,M,ASY,120,85,0,Normal,140,N,0,Up,0
101
+ 41,M,ATA,125,269,0,Normal,144,N,0,Up,0
102
+ 65,M,ASY,130,275,0,ST,115,Y,1,Flat,1
103
+ 51,M,ASY,130,179,0,Normal,100,N,0,Up,0
104
+ 40,F,ASY,150,392,0,Normal,130,N,2,Flat,1
105
+ 40,M,ASY,120,466,1,Normal,152,Y,1,Flat,1
106
+ 46,M,ASY,118,186,0,Normal,124,N,0,Flat,1
107
+ 57,M,ATA,140,260,1,Normal,140,N,0,Up,0
108
+ 48,F,ASY,120,254,0,ST,110,N,0,Up,0
109
+ 34,M,ATA,150,214,0,ST,168,N,0,Up,0
110
+ 50,M,ASY,140,129,0,Normal,135,N,0,Up,0
111
+ 39,M,ATA,190,241,0,Normal,106,N,0,Up,0
112
+ 59,F,ATA,130,188,0,Normal,124,N,1,Flat,0
113
+ 57,M,ASY,150,255,0,Normal,92,Y,3,Flat,1
114
+ 47,M,ASY,140,276,1,Normal,125,Y,0,Up,0
115
+ 38,M,ATA,140,297,0,Normal,150,N,0,Up,0
116
+ 49,F,NAP,130,207,0,ST,135,N,0,Up,0
117
+ 33,F,ASY,100,246,0,Normal,150,Y,1,Flat,1
118
+ 38,M,ASY,120,282,0,Normal,170,N,0,Flat,1
119
+ 59,F,ASY,130,338,1,ST,130,Y,1.5,Flat,1
120
+ 35,F,TA,120,160,0,ST,185,N,0,Up,0
121
+ 34,M,TA,140,156,0,Normal,180,N,0,Flat,1
122
+ 47,F,NAP,135,248,1,Normal,170,N,0,Flat,1
123
+ 52,F,NAP,125,272,0,Normal,139,N,0,Up,0
124
+ 46,M,ASY,110,240,0,ST,140,N,0,Up,0
125
+ 58,F,ATA,180,393,0,Normal,110,Y,1,Flat,1
126
+ 58,M,ATA,130,230,0,Normal,150,N,0,Up,0
127
+ 54,M,ATA,120,246,0,Normal,110,N,0,Up,0
128
+ 34,F,ATA,130,161,0,Normal,190,N,0,Up,0
129
+ 48,F,ASY,108,163,0,Normal,175,N,2,Up,0
130
+ 54,F,ATA,120,230,1,Normal,140,N,0,Up,0
131
+ 42,M,NAP,120,228,0,Normal,152,Y,1.5,Flat,0
132
+ 38,M,NAP,145,292,0,Normal,130,N,0,Up,0
133
+ 46,M,ASY,110,202,0,Normal,150,Y,0,Flat,1
134
+ 56,M,ASY,170,388,0,ST,122,Y,2,Flat,1
135
+ 56,M,ASY,150,230,0,ST,124,Y,1.5,Flat,1
136
+ 61,F,ASY,130,294,0,ST,120,Y,1,Flat,0
137
+ 49,M,NAP,115,265,0,Normal,175,N,0,Flat,1
138
+ 43,F,ATA,120,215,0,ST,175,N,0,Up,0
139
+ 39,M,ATA,120,241,0,ST,146,N,2,Up,0
140
+ 54,M,ASY,140,166,0,Normal,118,Y,0,Flat,1
141
+ 43,M,ASY,150,247,0,Normal,130,Y,2,Flat,1
142
+ 52,M,ASY,160,331,0,Normal,94,Y,2.5,Flat,1
143
+ 50,M,ASY,140,341,0,ST,125,Y,2.5,Flat,1
144
+ 47,M,ASY,160,291,0,ST,158,Y,3,Flat,1
145
+ 53,M,ASY,140,243,0,Normal,155,N,0,Up,0
146
+ 56,F,ATA,120,279,0,Normal,150,N,1,Flat,1
147
+ 39,M,ASY,110,273,0,Normal,132,N,0,Up,0
148
+ 42,M,ATA,120,198,0,Normal,155,N,0,Up,0
149
+ 43,F,ATA,120,249,0,ST,176,N,0,Up,0
150
+ 50,M,ATA,120,168,0,Normal,160,N,0,Up,0
151
+ 54,M,ASY,130,603,1,Normal,125,Y,1,Flat,1
152
+ 39,M,ATA,130,215,0,Normal,120,N,0,Up,0
153
+ 48,M,ATA,100,159,0,Normal,100,N,0,Up,0
154
+ 40,M,ATA,130,275,0,Normal,150,N,0,Up,0
155
+ 55,M,ASY,120,270,0,Normal,140,N,0,Up,0
156
+ 41,M,ATA,120,291,0,ST,160,N,0,Up,0
157
+ 56,M,ASY,155,342,1,Normal,150,Y,3,Flat,1
158
+ 38,M,ASY,110,190,0,Normal,150,Y,1,Flat,1
159
+ 49,M,ASY,140,185,0,Normal,130,N,0,Up,0
160
+ 44,M,ASY,130,290,0,Normal,100,Y,2,Flat,1
161
+ 54,M,ATA,160,195,0,ST,130,N,1,Up,0
162
+ 59,M,ASY,140,264,1,LVH,119,Y,0,Flat,1
163
+ 49,M,ASY,128,212,0,Normal,96,Y,0,Flat,1
164
+ 47,M,ATA,160,263,0,Normal,174,N,0,Up,0
165
+ 42,M,ATA,120,196,0,Normal,150,N,0,Up,0
166
+ 52,F,ATA,140,225,0,Normal,140,N,0,Up,0
167
+ 46,M,TA,140,272,1,Normal,175,N,2,Flat,1
168
+ 50,M,ASY,140,231,0,ST,140,Y,5,Flat,1
169
+ 48,M,ATA,140,238,0,Normal,118,N,0,Up,0
170
+ 58,M,ASY,135,222,0,Normal,100,N,0,Up,0
171
+ 58,M,NAP,140,179,0,Normal,160,N,0,Up,0
172
+ 29,M,ATA,120,243,0,Normal,160,N,0,Up,0
173
+ 40,M,NAP,140,235,0,Normal,188,N,0,Up,0
174
+ 53,M,ATA,140,320,0,Normal,162,N,0,Up,0
175
+ 49,M,NAP,140,187,0,Normal,172,N,0,Up,0
176
+ 52,M,ASY,140,266,0,Normal,134,Y,2,Flat,1
177
+ 43,M,ASY,140,288,0,Normal,135,Y,2,Flat,1
178
+ 54,M,ASY,140,216,0,Normal,105,N,1.5,Flat,1
179
+ 59,M,ATA,140,287,0,Normal,150,N,0,Up,0
180
+ 37,M,NAP,130,194,0,Normal,150,N,0,Up,0
181
+ 46,F,ASY,130,238,0,Normal,90,N,0,Up,0
182
+ 52,M,ASY,130,225,0,Normal,120,Y,2,Flat,1
183
+ 51,M,ATA,130,224,0,Normal,150,N,0,Up,0
184
+ 52,M,ASY,140,404,0,Normal,124,Y,2,Flat,1
185
+ 46,M,ASY,110,238,0,ST,140,Y,1,Flat,0
186
+ 54,F,ATA,160,312,0,Normal,130,N,0,Up,0
187
+ 58,M,NAP,160,211,1,ST,92,N,0,Flat,1
188
+ 58,M,ATA,130,251,0,Normal,110,N,0,Up,0
189
+ 41,M,ASY,120,237,1,Normal,138,Y,1,Flat,1
190
+ 50,F,ASY,120,328,0,Normal,110,Y,1,Flat,0
191
+ 53,M,ASY,180,285,0,ST,120,Y,1.5,Flat,1
192
+ 46,M,ASY,180,280,0,ST,120,N,0,Up,0
193
+ 50,M,ATA,170,209,0,ST,116,N,0,Up,0
194
+ 48,M,ATA,130,245,0,Normal,160,N,0,Up,0
195
+ 45,M,NAP,135,192,0,Normal,110,N,0,Up,0
196
+ 41,F,ATA,125,184,0,Normal,180,N,0,Up,0
197
+ 62,F,TA,160,193,0,Normal,116,N,0,Up,0
198
+ 49,M,ASY,120,297,0,Normal,132,N,1,Flat,0
199
+ 42,M,ATA,150,268,0,Normal,136,N,0,Up,0
200
+ 53,M,ASY,120,246,0,Normal,116,Y,0,Flat,1
201
+ 57,F,TA,130,308,0,Normal,98,N,1,Flat,0
202
+ 47,M,TA,110,249,0,Normal,150,N,0,Up,0
203
+ 46,M,NAP,120,230,0,Normal,150,N,0,Up,0
204
+ 42,M,NAP,160,147,0,Normal,146,N,0,Up,0
205
+ 31,F,ATA,100,219,0,ST,150,N,0,Up,0
206
+ 56,M,ATA,130,184,0,Normal,100,N,0,Up,0
207
+ 50,M,ASY,150,215,0,Normal,140,Y,0,Up,0
208
+ 35,M,ATA,120,308,0,LVH,180,N,0,Up,0
209
+ 35,M,ATA,110,257,0,Normal,140,N,0,Flat,1
210
+ 28,M,ATA,130,132,0,LVH,185,N,0,Up,0
211
+ 54,M,ASY,125,216,0,Normal,140,N,0,Flat,1
212
+ 48,M,ASY,106,263,1,Normal,110,N,0,Flat,1
213
+ 50,F,NAP,140,288,0,Normal,140,Y,0,Flat,1
214
+ 56,M,NAP,130,276,0,Normal,128,Y,1,Up,0
215
+ 56,F,NAP,130,219,0,ST,164,N,0,Up,0
216
+ 47,M,ASY,150,226,0,Normal,98,Y,1.5,Flat,1
217
+ 30,F,TA,170,237,0,ST,170,N,0,Up,0
218
+ 39,M,ASY,110,280,0,Normal,150,N,0,Flat,1
219
+ 54,M,NAP,120,217,0,Normal,137,N,0,Up,0
220
+ 55,M,ATA,140,196,0,Normal,150,N,0,Up,0
221
+ 29,M,ATA,140,263,0,Normal,170,N,0,Up,0
222
+ 46,M,ASY,130,222,0,Normal,112,N,0,Flat,1
223
+ 51,F,ASY,160,303,0,Normal,150,Y,1,Flat,1
224
+ 48,F,NAP,120,195,0,Normal,125,N,0,Up,0
225
+ 33,M,NAP,120,298,0,Normal,185,N,0,Up,0
226
+ 55,M,ATA,120,256,1,Normal,137,N,0,Up,0
227
+ 50,M,ASY,145,264,0,Normal,150,N,0,Flat,1
228
+ 53,M,NAP,120,195,0,Normal,140,N,0,Up,0
229
+ 38,M,ASY,92,117,0,Normal,134,Y,2.5,Flat,1
230
+ 41,M,ATA,120,295,0,Normal,170,N,0,Up,0
231
+ 37,F,ASY,130,173,0,ST,184,N,0,Up,0
232
+ 37,M,ASY,130,315,0,Normal,158,N,0,Up,0
233
+ 40,M,NAP,130,281,0,Normal,167,N,0,Up,0
234
+ 38,F,ATA,120,275,0,Normal,129,N,0,Up,0
235
+ 41,M,ASY,112,250,0,Normal,142,N,0,Up,0
236
+ 54,F,ATA,140,309,0,ST,140,N,0,Up,0
237
+ 39,M,ATA,120,200,0,Normal,160,Y,1,Flat,0
238
+ 41,M,ASY,120,336,0,Normal,118,Y,3,Flat,1
239
+ 55,M,TA,140,295,0,Normal,136,N,0,Flat,1
240
+ 48,M,ASY,160,355,0,Normal,99,Y,2,Flat,1
241
+ 48,M,ASY,160,193,0,Normal,102,Y,3,Flat,1
242
+ 55,M,ATA,145,326,0,Normal,155,N,0,Up,0
243
+ 54,M,ASY,200,198,0,Normal,142,Y,2,Flat,1
244
+ 55,M,ATA,160,292,1,Normal,143,Y,2,Flat,1
245
+ 43,F,ATA,120,266,0,Normal,118,N,0,Up,0
246
+ 48,M,ASY,160,268,0,Normal,103,Y,1,Flat,1
247
+ 54,M,TA,120,171,0,Normal,137,N,2,Up,0
248
+ 54,M,NAP,120,237,0,Normal,150,Y,1.5,Flat,1
249
+ 48,M,ASY,122,275,1,ST,150,Y,2,Down,1
250
+ 45,M,ASY,130,219,0,ST,130,Y,1,Flat,1
251
+ 49,M,ASY,130,341,0,Normal,120,Y,1,Flat,1
252
+ 44,M,ASY,135,491,0,Normal,135,N,0,Flat,1
253
+ 48,M,ASY,120,260,0,Normal,115,N,2,Flat,1
254
+ 61,M,ASY,125,292,0,ST,115,Y,0,Up,0
255
+ 62,M,ATA,140,271,0,Normal,152,N,1,Up,0
256
+ 55,M,ASY,145,248,0,Normal,96,Y,2,Flat,1
257
+ 53,F,NAP,120,274,0,Normal,130,N,0,Up,0
258
+ 55,F,ATA,130,394,0,LVH,150,N,0,Up,0
259
+ 36,M,NAP,150,160,0,Normal,172,N,0,Up,0
260
+ 51,F,NAP,150,200,0,Normal,120,N,0.5,Up,0
261
+ 55,F,ATA,122,320,0,Normal,155,N,0,Up,0
262
+ 46,M,ATA,140,275,0,Normal,165,Y,0,Up,0
263
+ 54,F,ATA,120,221,0,Normal,138,N,1,Up,0
264
+ 46,M,ASY,120,231,0,Normal,115,Y,0,Flat,1
265
+ 59,M,ASY,130,126,0,Normal,125,N,0,Flat,1
266
+ 47,M,NAP,140,193,0,Normal,145,Y,1,Flat,1
267
+ 54,M,ATA,160,305,0,Normal,175,N,0,Up,0
268
+ 52,M,ASY,130,298,0,Normal,110,Y,1,Flat,1
269
+ 34,M,ATA,98,220,0,Normal,150,N,0,Up,0
270
+ 54,M,ASY,130,242,0,Normal,91,Y,1,Flat,1
271
+ 47,F,NAP,130,235,0,Normal,145,N,2,Flat,0
272
+ 45,M,ASY,120,225,0,Normal,140,N,0,Up,0
273
+ 32,F,ATA,105,198,0,Normal,165,N,0,Up,0
274
+ 55,M,ASY,140,201,0,Normal,130,Y,3,Flat,1
275
+ 55,M,NAP,120,220,0,LVH,134,N,0,Up,0
276
+ 45,F,ATA,180,295,0,Normal,180,N,0,Up,0
277
+ 59,M,NAP,180,213,0,Normal,100,N,0,Up,0
278
+ 51,M,NAP,135,160,0,Normal,150,N,2,Flat,1
279
+ 52,M,ASY,170,223,0,Normal,126,Y,1.5,Flat,1
280
+ 57,F,ASY,180,347,0,ST,126,Y,0.8,Flat,0
281
+ 54,F,ATA,130,253,0,ST,155,N,0,Up,0
282
+ 60,M,NAP,120,246,0,LVH,135,N,0,Up,0
283
+ 49,M,ASY,150,222,0,Normal,122,N,2,Flat,1
284
+ 51,F,NAP,130,220,0,Normal,160,Y,2,Up,0
285
+ 55,F,ATA,110,344,0,ST,160,N,0,Up,0
286
+ 42,M,ASY,140,358,0,Normal,170,N,0,Up,0
287
+ 51,F,NAP,110,190,0,Normal,120,N,0,Up,0
288
+ 59,M,ASY,140,169,0,Normal,140,N,0,Up,0
289
+ 53,M,ATA,120,181,0,Normal,132,N,0,Up,0
290
+ 48,F,ATA,133,308,0,ST,156,N,2,Up,0
291
+ 36,M,ATA,120,166,0,Normal,180,N,0,Up,0
292
+ 48,M,NAP,110,211,0,Normal,138,N,0,Up,0
293
+ 47,F,ATA,140,257,0,Normal,135,N,1,Up,0
294
+ 53,M,ASY,130,182,0,Normal,148,N,0,Up,0
295
+ 65,M,ASY,115,0,0,Normal,93,Y,0,Flat,1
296
+ 32,M,TA,95,0,1,Normal,127,N,0.7,Up,1
297
+ 61,M,ASY,105,0,1,Normal,110,Y,1.5,Up,1
298
+ 50,M,ASY,145,0,1,Normal,139,Y,0.7,Flat,1
299
+ 57,M,ASY,110,0,1,ST,131,Y,1.4,Up,1
300
+ 51,M,ASY,110,0,1,Normal,92,N,0,Flat,1
301
+ 47,M,ASY,110,0,1,ST,149,N,2.1,Up,1
302
+ 60,M,ASY,160,0,1,Normal,149,N,0.4,Flat,1
303
+ 55,M,ATA,140,0,0,ST,150,N,0.2,Up,0
304
+ 53,M,ASY,125,0,1,Normal,120,N,1.5,Up,1
305
+ 62,F,ASY,120,0,1,ST,123,Y,1.7,Down,1
306
+ 51,M,ASY,95,0,1,Normal,126,N,2.2,Flat,1
307
+ 51,F,ASY,120,0,1,Normal,127,Y,1.5,Up,1
308
+ 55,M,ASY,115,0,1,Normal,155,N,0.1,Flat,1
309
+ 53,M,ATA,130,0,0,ST,120,N,0.7,Down,0
310
+ 58,M,ASY,115,0,1,Normal,138,N,0.5,Up,1
311
+ 57,M,ASY,95,0,1,Normal,182,N,0.7,Down,1
312
+ 65,M,ASY,155,0,0,Normal,154,N,1,Up,0
313
+ 60,M,ASY,125,0,1,Normal,110,N,0.1,Up,1
314
+ 41,M,ASY,125,0,1,Normal,176,N,1.6,Up,1
315
+ 34,M,ASY,115,0,1,Normal,154,N,0.2,Up,1
316
+ 53,M,ASY,80,0,0,Normal,141,Y,2,Down,0
317
+ 74,M,ATA,145,0,1,ST,123,N,1.3,Up,1
318
+ 57,M,NAP,105,0,1,Normal,148,N,0.3,Flat,1
319
+ 56,M,ASY,140,0,1,Normal,121,Y,1.8,Up,1
320
+ 61,M,ASY,130,0,1,Normal,77,N,2.5,Flat,1
321
+ 68,M,ASY,145,0,1,Normal,136,N,1.8,Up,1
322
+ 59,M,NAP,125,0,1,Normal,175,N,2.6,Flat,1
323
+ 63,M,ASY,100,0,1,Normal,109,N,-0.9,Flat,1
324
+ 38,F,ASY,105,0,1,Normal,166,N,2.8,Up,1
325
+ 62,M,ASY,115,0,1,Normal,128,Y,2.5,Down,1
326
+ 46,M,ASY,100,0,1,ST,133,N,-2.6,Flat,1
327
+ 42,M,ASY,105,0,1,Normal,128,Y,-1.5,Down,1
328
+ 45,M,NAP,110,0,0,Normal,138,N,-0.1,Up,0
329
+ 59,M,ASY,125,0,1,Normal,119,Y,0.9,Up,1
330
+ 52,M,ASY,95,0,1,Normal,82,Y,0.8,Flat,1
331
+ 60,M,ASY,130,0,1,ST,130,Y,1.1,Down,1
332
+ 60,M,NAP,115,0,1,Normal,143,N,2.4,Up,1
333
+ 56,M,ASY,115,0,1,ST,82,N,-1,Up,1
334
+ 38,M,NAP,100,0,0,Normal,179,N,-1.1,Up,0
335
+ 40,M,ASY,95,0,1,ST,144,N,0,Up,1
336
+ 51,M,ASY,130,0,1,Normal,170,N,-0.7,Up,1
337
+ 62,M,TA,120,0,1,LVH,134,N,-0.8,Flat,1
338
+ 72,M,NAP,160,0,0,LVH,114,N,1.6,Flat,0
339
+ 63,M,ASY,150,0,1,ST,154,N,3.7,Up,1
340
+ 63,M,ASY,140,0,1,LVH,149,N,2,Up,1
341
+ 64,F,ASY,95,0,1,Normal,145,N,1.1,Down,1
342
+ 43,M,ASY,100,0,1,Normal,122,N,1.5,Down,1
343
+ 64,M,ASY,110,0,1,Normal,114,Y,1.3,Down,1
344
+ 61,M,ASY,110,0,1,Normal,113,N,1.4,Flat,1
345
+ 52,M,ASY,130,0,1,Normal,120,N,0,Flat,1
346
+ 51,M,ASY,120,0,1,Normal,104,N,0,Flat,1
347
+ 69,M,ASY,135,0,0,Normal,130,N,0,Flat,1
348
+ 59,M,ASY,120,0,0,Normal,115,N,0,Flat,1
349
+ 48,M,ASY,115,0,1,Normal,128,N,0,Flat,1
350
+ 69,M,ASY,137,0,0,ST,104,Y,1.6,Flat,1
351
+ 36,M,ASY,110,0,1,Normal,125,Y,1,Flat,1
352
+ 53,M,ASY,120,0,1,Normal,120,N,0,Flat,1
353
+ 43,M,ASY,140,0,0,ST,140,Y,0.5,Up,1
354
+ 56,M,ASY,120,0,0,ST,100,Y,-1,Down,1
355
+ 58,M,ASY,130,0,0,ST,100,Y,1,Flat,1
356
+ 55,M,ASY,120,0,0,ST,92,N,0.3,Up,1
357
+ 67,M,TA,145,0,0,LVH,125,N,0,Flat,1
358
+ 46,M,ASY,115,0,0,Normal,113,Y,1.5,Flat,1
359
+ 53,M,ATA,120,0,0,Normal,95,N,0,Flat,1
360
+ 38,M,NAP,115,0,0,Normal,128,Y,0,Flat,1
361
+ 53,M,NAP,105,0,0,Normal,115,N,0,Flat,1
362
+ 62,M,NAP,160,0,0,Normal,72,Y,0,Flat,1
363
+ 47,M,ASY,160,0,0,Normal,124,Y,0,Flat,1
364
+ 56,M,NAP,155,0,0,ST,99,N,0,Flat,1
365
+ 56,M,ASY,120,0,0,ST,148,N,0,Flat,1
366
+ 56,M,NAP,120,0,0,Normal,97,N,0,Flat,0
367
+ 64,F,ASY,200,0,0,Normal,140,Y,1,Flat,1
368
+ 61,M,ASY,150,0,0,Normal,117,Y,2,Flat,1
369
+ 68,M,ASY,135,0,0,ST,120,Y,0,Up,1
370
+ 57,M,ASY,140,0,0,Normal,120,Y,2,Flat,1
371
+ 63,M,ASY,150,0,0,Normal,86,Y,2,Flat,1
372
+ 60,M,ASY,135,0,0,Normal,63,Y,0.5,Up,1
373
+ 66,M,ASY,150,0,0,Normal,108,Y,2,Flat,1
374
+ 63,M,ASY,185,0,0,Normal,98,Y,0,Up,1
375
+ 59,M,ASY,135,0,0,Normal,115,Y,1,Flat,1
376
+ 61,M,ASY,125,0,0,Normal,105,Y,0,Down,1
377
+ 73,F,NAP,160,0,0,ST,121,N,0,Up,1
378
+ 47,M,NAP,155,0,0,Normal,118,Y,1,Flat,1
379
+ 65,M,ASY,160,0,1,ST,122,N,1.2,Flat,1
380
+ 70,M,ASY,140,0,1,Normal,157,Y,2,Flat,1
381
+ 50,M,ASY,120,0,0,ST,156,Y,0,Up,1
382
+ 60,M,ASY,160,0,0,ST,99,Y,0.5,Flat,1
383
+ 50,M,ASY,115,0,0,Normal,120,Y,0.5,Flat,1
384
+ 43,M,ASY,115,0,0,Normal,145,Y,2,Flat,1
385
+ 38,F,ASY,110,0,0,Normal,156,N,0,Flat,1
386
+ 54,M,ASY,120,0,0,Normal,155,N,0,Flat,1
387
+ 61,M,ASY,150,0,0,Normal,105,Y,0,Flat,1
388
+ 42,M,ASY,145,0,0,Normal,99,Y,0,Flat,1
389
+ 53,M,ASY,130,0,0,LVH,135,Y,1,Flat,1
390
+ 55,M,ASY,140,0,0,Normal,83,N,0,Flat,1
391
+ 61,M,ASY,160,0,1,ST,145,N,1,Flat,1
392
+ 51,M,ASY,140,0,0,Normal,60,N,0,Flat,1
393
+ 70,M,ASY,115,0,0,ST,92,Y,0,Flat,1
394
+ 61,M,ASY,130,0,0,LVH,115,N,0,Flat,1
395
+ 38,M,ASY,150,0,1,Normal,120,Y,0.7,Flat,1
396
+ 57,M,ASY,160,0,1,Normal,98,Y,2,Flat,1
397
+ 38,M,ASY,135,0,1,Normal,150,N,0,Flat,1
398
+ 62,F,TA,140,0,1,Normal,143,N,0,Flat,1
399
+ 58,M,ASY,170,0,1,ST,105,Y,0,Flat,1
400
+ 52,M,ASY,165,0,1,Normal,122,Y,1,Up,1
401
+ 61,M,NAP,200,0,1,ST,70,N,0,Flat,1
402
+ 50,F,ASY,160,0,1,Normal,110,N,0,Flat,1
403
+ 51,M,ASY,130,0,1,ST,163,N,0,Flat,1
404
+ 65,M,ASY,145,0,1,ST,67,N,0.7,Flat,1
405
+ 52,M,ASY,135,0,1,Normal,128,Y,2,Flat,1
406
+ 47,M,NAP,110,0,1,Normal,120,Y,0,Flat,1
407
+ 35,M,ASY,120,0,1,Normal,130,Y,1.2,Flat,1
408
+ 57,M,ASY,140,0,1,Normal,100,Y,0,Flat,1
409
+ 62,M,ASY,115,0,1,Normal,72,Y,-0.5,Flat,1
410
+ 59,M,ASY,110,0,1,Normal,94,N,0,Flat,1
411
+ 53,M,NAP,160,0,1,LVH,122,Y,0,Flat,1
412
+ 62,M,ASY,150,0,1,ST,78,N,2,Flat,1
413
+ 54,M,ASY,180,0,1,Normal,150,N,1.5,Flat,1
414
+ 56,M,ASY,125,0,1,Normal,103,Y,1,Flat,1
415
+ 56,M,NAP,125,0,1,Normal,98,N,-2,Flat,1
416
+ 54,M,ASY,130,0,1,Normal,110,Y,3,Flat,1
417
+ 66,F,ASY,155,0,1,Normal,90,N,0,Flat,1
418
+ 63,M,ASY,140,260,0,ST,112,Y,3,Flat,1
419
+ 44,M,ASY,130,209,0,ST,127,N,0,Up,0
420
+ 60,M,ASY,132,218,0,ST,140,Y,1.5,Down,1
421
+ 55,M,ASY,142,228,0,ST,149,Y,2.5,Up,1
422
+ 66,M,NAP,110,213,1,LVH,99,Y,1.3,Flat,0
423
+ 66,M,NAP,120,0,0,ST,120,N,-0.5,Up,0
424
+ 65,M,ASY,150,236,1,ST,105,Y,0,Flat,1
425
+ 60,M,NAP,180,0,0,ST,140,Y,1.5,Flat,0
426
+ 60,M,NAP,120,0,1,Normal,141,Y,2,Up,1
427
+ 60,M,ATA,160,267,1,ST,157,N,0.5,Flat,1
428
+ 56,M,ATA,126,166,0,ST,140,N,0,Up,0
429
+ 59,M,ASY,140,0,0,ST,117,Y,1,Flat,1
430
+ 62,M,ASY,110,0,0,Normal,120,Y,0.5,Flat,1
431
+ 63,M,NAP,133,0,0,LVH,120,Y,1,Flat,1
432
+ 57,M,ASY,128,0,1,ST,148,Y,1,Flat,1
433
+ 62,M,ASY,120,220,0,ST,86,N,0,Up,0
434
+ 63,M,ASY,170,177,0,Normal,84,Y,2.5,Down,1
435
+ 46,M,ASY,110,236,0,Normal,125,Y,2,Flat,1
436
+ 63,M,ASY,126,0,0,ST,120,N,1.5,Down,0
437
+ 60,M,ASY,152,0,0,ST,118,Y,0,Up,0
438
+ 58,M,ASY,116,0,0,Normal,124,N,1,Up,1
439
+ 64,M,ASY,120,0,1,ST,106,N,2,Flat,1
440
+ 63,M,NAP,130,0,0,ST,111,Y,0,Flat,1
441
+ 74,M,NAP,138,0,0,Normal,116,N,0.2,Up,0
442
+ 52,M,NAP,128,0,0,ST,180,N,3,Up,1
443
+ 69,M,ASY,130,0,1,ST,129,N,1,Flat,1
444
+ 51,M,ASY,128,0,1,ST,125,Y,1.2,Flat,1
445
+ 60,M,ASY,130,186,1,ST,140,Y,0.5,Flat,1
446
+ 56,M,ASY,120,100,0,Normal,120,Y,1.5,Flat,1
447
+ 55,M,NAP,136,228,0,ST,124,Y,1.6,Flat,1
448
+ 54,M,ASY,130,0,0,ST,117,Y,1.4,Flat,1
449
+ 77,M,ASY,124,171,0,ST,110,Y,2,Up,1
450
+ 63,M,ASY,160,230,1,Normal,105,Y,1,Flat,1
451
+ 55,M,NAP,0,0,0,Normal,155,N,1.5,Flat,1
452
+ 52,M,NAP,122,0,0,Normal,110,Y,2,Down,1
453
+ 64,M,ASY,144,0,0,ST,122,Y,1,Flat,1
454
+ 60,M,ASY,140,281,0,ST,118,Y,1.5,Flat,1
455
+ 60,M,ASY,120,0,0,Normal,133,Y,2,Up,0
456
+ 58,M,ASY,136,203,1,Normal,123,Y,1.2,Flat,1
457
+ 59,M,ASY,154,0,0,ST,131,Y,1.5,Up,0
458
+ 61,M,NAP,120,0,0,Normal,80,Y,0,Flat,1
459
+ 40,M,ASY,125,0,1,Normal,165,N,0,Flat,1
460
+ 61,M,ASY,134,0,1,ST,86,N,1.5,Flat,1
461
+ 41,M,ASY,104,0,0,ST,111,N,0,Up,0
462
+ 57,M,ASY,139,277,1,ST,118,Y,1.9,Flat,1
463
+ 63,M,ASY,136,0,0,Normal,84,Y,0,Flat,1
464
+ 59,M,ASY,122,233,0,Normal,117,Y,1.3,Down,1
465
+ 51,M,ASY,128,0,0,Normal,107,N,0,Up,0
466
+ 59,M,NAP,131,0,0,Normal,128,Y,2,Down,1
467
+ 42,M,NAP,134,240,0,Normal,160,N,0,Up,0
468
+ 55,M,NAP,120,0,0,ST,125,Y,2.5,Flat,1
469
+ 63,F,ATA,132,0,0,Normal,130,N,0.1,Up,0
470
+ 62,M,ASY,152,153,0,ST,97,Y,1.6,Up,1
471
+ 56,M,ATA,124,224,1,Normal,161,N,2,Flat,0
472
+ 53,M,ASY,126,0,0,Normal,106,N,0,Flat,1
473
+ 68,M,ASY,138,0,0,Normal,130,Y,3,Flat,1
474
+ 53,M,ASY,154,0,1,ST,140,Y,1.5,Flat,1
475
+ 60,M,NAP,141,316,1,ST,122,Y,1.7,Flat,1
476
+ 62,M,ATA,131,0,0,Normal,130,N,0.1,Up,0
477
+ 59,M,ASY,178,0,1,LVH,120,Y,0,Flat,1
478
+ 51,M,ASY,132,218,1,LVH,139,N,0.1,Up,0
479
+ 61,M,ASY,110,0,1,Normal,108,Y,2,Down,1
480
+ 57,M,ASY,130,311,1,ST,148,Y,2,Flat,1
481
+ 56,M,NAP,170,0,0,LVH,123,Y,2.5,Flat,1
482
+ 58,M,ATA,126,0,1,Normal,110,Y,2,Flat,1
483
+ 69,M,NAP,140,0,1,ST,118,N,2.5,Down,1
484
+ 67,M,TA,142,270,1,Normal,125,N,2.5,Up,1
485
+ 58,M,ASY,120,0,0,LVH,106,Y,1.5,Down,1
486
+ 65,M,ASY,134,0,0,Normal,112,Y,1.1,Flat,1
487
+ 63,M,ATA,139,217,1,ST,128,Y,1.2,Flat,1
488
+ 55,M,ATA,110,214,1,ST,180,N,0.4,Up,0
489
+ 57,M,ASY,140,214,0,ST,144,Y,2,Flat,1
490
+ 65,M,TA,140,252,0,Normal,135,N,0.3,Up,0
491
+ 54,M,ASY,136,220,0,Normal,140,Y,3,Flat,1
492
+ 72,M,NAP,120,214,0,Normal,102,Y,1,Flat,1
493
+ 75,M,ASY,170,203,1,ST,108,N,0,Flat,1
494
+ 49,M,TA,130,0,0,ST,145,N,3,Flat,1
495
+ 51,M,NAP,137,339,0,Normal,127,Y,1.7,Flat,1
496
+ 60,M,ASY,142,216,0,Normal,110,Y,2.5,Flat,1
497
+ 64,F,ASY,142,276,0,Normal,140,Y,1,Flat,1
498
+ 58,M,ASY,132,458,1,Normal,69,N,1,Down,0
499
+ 61,M,ASY,146,241,0,Normal,148,Y,3,Down,1
500
+ 67,M,ASY,160,384,1,ST,130,Y,0,Flat,1
501
+ 62,M,ASY,135,297,0,Normal,130,Y,1,Flat,1
502
+ 65,M,ASY,136,248,0,Normal,140,Y,4,Down,1
503
+ 63,M,ASY,130,308,0,Normal,138,Y,2,Flat,1
504
+ 69,M,ASY,140,208,0,ST,140,Y,2,Flat,1
505
+ 51,M,ASY,132,227,1,ST,138,N,0.2,Up,0
506
+ 62,M,ASY,158,210,1,Normal,112,Y,3,Down,1
507
+ 55,M,NAP,136,245,1,ST,131,Y,1.2,Flat,1
508
+ 75,M,ASY,136,225,0,Normal,112,Y,3,Flat,1
509
+ 40,M,NAP,106,240,0,Normal,80,Y,0,Up,0
510
+ 67,M,ASY,120,0,1,Normal,150,N,1.5,Down,1
511
+ 58,M,ASY,110,198,0,Normal,110,N,0,Flat,1
512
+ 60,M,ASY,136,195,0,Normal,126,N,0.3,Up,0
513
+ 63,M,ASY,160,267,1,ST,88,Y,2,Flat,1
514
+ 35,M,NAP,123,161,0,ST,153,N,-0.1,Up,0
515
+ 62,M,TA,112,258,0,ST,150,Y,1.3,Flat,1
516
+ 43,M,ASY,122,0,0,Normal,120,N,0.5,Up,1
517
+ 63,M,NAP,130,0,1,ST,160,N,3,Flat,0
518
+ 68,M,NAP,150,195,1,Normal,132,N,0,Flat,1
519
+ 65,M,ASY,150,235,0,Normal,120,Y,1.5,Flat,1
520
+ 48,M,NAP,102,0,1,ST,110,Y,1,Down,1
521
+ 63,M,ASY,96,305,0,ST,121,Y,1,Up,1
522
+ 64,M,ASY,130,223,0,ST,128,N,0.5,Flat,0
523
+ 61,M,ASY,120,282,0,ST,135,Y,4,Down,1
524
+ 50,M,ASY,144,349,0,LVH,120,Y,1,Up,1
525
+ 59,M,ASY,124,160,0,Normal,117,Y,1,Flat,1
526
+ 55,M,ASY,150,160,0,ST,150,N,0,Up,0
527
+ 45,M,NAP,130,236,0,Normal,144,N,0.1,Up,0
528
+ 65,M,ASY,144,312,0,LVH,113,Y,1.7,Flat,1
529
+ 61,M,ATA,139,283,0,Normal,135,N,0.3,Up,0
530
+ 49,M,NAP,131,142,0,Normal,127,Y,1.5,Flat,1
531
+ 72,M,ASY,143,211,0,Normal,109,Y,1.4,Flat,1
532
+ 50,M,ASY,133,218,0,Normal,128,Y,1.1,Flat,1
533
+ 64,M,ASY,143,306,1,ST,115,Y,1.8,Flat,1
534
+ 55,M,ASY,116,186,1,ST,102,N,0,Flat,1
535
+ 63,M,ASY,110,252,0,ST,140,Y,2,Flat,1
536
+ 59,M,ASY,125,222,0,Normal,135,Y,2.5,Down,1
537
+ 56,M,ASY,130,0,0,LVH,122,Y,1,Flat,1
538
+ 62,M,NAP,133,0,1,ST,119,Y,1.2,Flat,1
539
+ 74,M,ASY,150,258,1,ST,130,Y,4,Down,1
540
+ 54,M,ASY,130,202,1,Normal,112,Y,2,Flat,1
541
+ 57,M,ASY,110,197,0,LVH,100,N,0,Up,0
542
+ 62,M,NAP,138,204,0,ST,122,Y,1.2,Flat,1
543
+ 76,M,NAP,104,113,0,LVH,120,N,3.5,Down,1
544
+ 54,F,ASY,138,274,0,Normal,105,Y,1.5,Flat,1
545
+ 70,M,ASY,170,192,0,ST,129,Y,3,Down,1
546
+ 61,F,ATA,140,298,1,Normal,120,Y,0,Up,0
547
+ 48,M,ASY,132,272,0,ST,139,N,0.2,Up,0
548
+ 48,M,NAP,132,220,1,ST,162,N,0,Flat,1
549
+ 61,M,TA,142,200,1,ST,100,N,1.5,Down,1
550
+ 66,M,ASY,112,261,0,Normal,140,N,1.5,Up,1
551
+ 68,M,TA,139,181,1,ST,135,N,0.2,Up,0
552
+ 55,M,ASY,172,260,0,Normal,73,N,2,Flat,1
553
+ 62,M,NAP,120,220,0,LVH,86,N,0,Up,0
554
+ 71,M,NAP,144,221,0,Normal,108,Y,1.8,Flat,1
555
+ 74,M,TA,145,216,1,Normal,116,Y,1.8,Flat,1
556
+ 53,M,NAP,155,175,1,ST,160,N,0.3,Up,0
557
+ 58,M,NAP,150,219,0,ST,118,Y,0,Flat,1
558
+ 75,M,ASY,160,310,1,Normal,112,Y,2,Down,0
559
+ 56,M,NAP,137,208,1,ST,122,Y,1.8,Flat,1
560
+ 58,M,NAP,137,232,0,ST,124,Y,1.4,Flat,1
561
+ 64,M,ASY,134,273,0,Normal,102,Y,4,Down,1
562
+ 54,M,NAP,133,203,0,ST,137,N,0.2,Up,0
563
+ 54,M,ATA,132,182,0,ST,141,N,0.1,Up,0
564
+ 59,M,ASY,140,274,0,Normal,154,Y,2,Flat,0
565
+ 55,M,ASY,135,204,1,ST,126,Y,1.1,Flat,1
566
+ 57,M,ASY,144,270,1,ST,160,Y,2,Flat,1
567
+ 61,M,ASY,141,292,0,ST,115,Y,1.7,Flat,1
568
+ 41,M,ASY,150,171,0,Normal,128,Y,1.5,Flat,0
569
+ 71,M,ASY,130,221,0,ST,115,Y,0,Flat,1
570
+ 38,M,ASY,110,289,0,Normal,105,Y,1.5,Down,1
571
+ 55,M,ASY,158,217,0,Normal,110,Y,2.5,Flat,1
572
+ 56,M,ASY,128,223,0,ST,119,Y,2,Down,1
573
+ 69,M,ASY,140,110,1,Normal,109,Y,1.5,Flat,1
574
+ 64,M,ASY,150,193,0,ST,135,Y,0.5,Flat,1
575
+ 72,M,ASY,160,123,1,LVH,130,N,1.5,Flat,1
576
+ 69,M,ASY,142,210,1,ST,112,Y,1.5,Flat,1
577
+ 56,M,ASY,137,282,1,Normal,126,Y,1.2,Flat,1
578
+ 62,M,ASY,139,170,0,ST,120,Y,3,Flat,1
579
+ 67,M,ASY,146,369,0,Normal,110,Y,1.9,Flat,1
580
+ 57,M,ASY,156,173,0,LVH,119,Y,3,Down,1
581
+ 69,M,ASY,145,289,1,ST,110,Y,1.8,Flat,1
582
+ 51,M,ASY,131,152,1,LVH,130,Y,1,Flat,1
583
+ 48,M,ASY,140,208,0,Normal,159,Y,1.5,Up,1
584
+ 69,M,ASY,122,216,1,LVH,84,Y,0,Flat,1
585
+ 69,M,NAP,142,271,0,LVH,126,N,0.3,Up,0
586
+ 64,M,ASY,141,244,1,ST,116,Y,1.5,Flat,1
587
+ 57,M,ATA,180,285,1,ST,120,N,0.8,Flat,1
588
+ 53,M,ASY,124,243,0,Normal,122,Y,2,Flat,1
589
+ 37,M,NAP,118,240,0,LVH,165,N,1,Flat,0
590
+ 67,M,ASY,140,219,0,ST,122,Y,2,Flat,1
591
+ 74,M,NAP,140,237,1,Normal,94,N,0,Flat,1
592
+ 63,M,ATA,136,165,0,ST,133,N,0.2,Up,0
593
+ 58,M,ASY,100,213,0,ST,110,N,0,Up,0
594
+ 61,M,ASY,190,287,1,LVH,150,Y,2,Down,1
595
+ 64,M,ASY,130,258,1,LVH,130,N,0,Flat,1
596
+ 58,M,ASY,160,256,1,LVH,113,Y,1,Up,1
597
+ 60,M,ASY,130,186,1,LVH,140,Y,0.5,Flat,1
598
+ 57,M,ASY,122,264,0,LVH,100,N,0,Flat,1
599
+ 55,M,NAP,133,185,0,ST,136,N,0.2,Up,0
600
+ 55,M,ASY,120,226,0,LVH,127,Y,1.7,Down,1
601
+ 56,M,ASY,130,203,1,Normal,98,N,1.5,Flat,1
602
+ 57,M,ASY,130,207,0,ST,96,Y,1,Flat,0
603
+ 61,M,NAP,140,284,0,Normal,123,Y,1.3,Flat,1
604
+ 61,M,NAP,120,337,0,Normal,98,Y,0,Flat,1
605
+ 74,M,ASY,155,310,0,Normal,112,Y,1.5,Down,1
606
+ 68,M,NAP,134,254,1,Normal,151,Y,0,Up,0
607
+ 51,F,ASY,114,258,1,LVH,96,N,1,Up,0
608
+ 62,M,ASY,160,254,1,ST,108,Y,3,Flat,1
609
+ 53,M,ASY,144,300,1,ST,128,Y,1.5,Flat,1
610
+ 62,M,ASY,158,170,0,ST,138,Y,0,Flat,1
611
+ 46,M,ASY,134,310,0,Normal,126,N,0,Flat,1
612
+ 54,F,ASY,127,333,1,ST,154,N,0,Flat,1
613
+ 62,M,TA,135,139,0,ST,137,N,0.2,Up,0
614
+ 55,M,ASY,122,223,1,ST,100,N,0,Flat,1
615
+ 58,M,ASY,140,385,1,LVH,135,N,0.3,Up,0
616
+ 62,M,ATA,120,254,0,LVH,93,Y,0,Flat,1
617
+ 70,M,ASY,130,322,0,LVH,109,N,2.4,Flat,1
618
+ 67,F,NAP,115,564,0,LVH,160,N,1.6,Flat,0
619
+ 57,M,ATA,124,261,0,Normal,141,N,0.3,Up,1
620
+ 64,M,ASY,128,263,0,Normal,105,Y,0.2,Flat,0
621
+ 74,F,ATA,120,269,0,LVH,121,Y,0.2,Up,0
622
+ 65,M,ASY,120,177,0,Normal,140,N,0.4,Up,0
623
+ 56,M,NAP,130,256,1,LVH,142,Y,0.6,Flat,1
624
+ 59,M,ASY,110,239,0,LVH,142,Y,1.2,Flat,1
625
+ 60,M,ASY,140,293,0,LVH,170,N,1.2,Flat,1
626
+ 63,F,ASY,150,407,0,LVH,154,N,4,Flat,1
627
+ 59,M,ASY,135,234,0,Normal,161,N,0.5,Flat,0
628
+ 53,M,ASY,142,226,0,LVH,111,Y,0,Up,0
629
+ 44,M,NAP,140,235,0,LVH,180,N,0,Up,0
630
+ 61,M,TA,134,234,0,Normal,145,N,2.6,Flat,1
631
+ 57,F,ASY,128,303,0,LVH,159,N,0,Up,0
632
+ 71,F,ASY,112,149,0,Normal,125,N,1.6,Flat,0
633
+ 46,M,ASY,140,311,0,Normal,120,Y,1.8,Flat,1
634
+ 53,M,ASY,140,203,1,LVH,155,Y,3.1,Down,1
635
+ 64,M,TA,110,211,0,LVH,144,Y,1.8,Flat,0
636
+ 40,M,TA,140,199,0,Normal,178,Y,1.4,Up,0
637
+ 67,M,ASY,120,229,0,LVH,129,Y,2.6,Flat,1
638
+ 48,M,ATA,130,245,0,LVH,180,N,0.2,Flat,0
639
+ 43,M,ASY,115,303,0,Normal,181,N,1.2,Flat,0
640
+ 47,M,ASY,112,204,0,Normal,143,N,0.1,Up,0
641
+ 54,F,ATA,132,288,1,LVH,159,Y,0,Up,0
642
+ 48,F,NAP,130,275,0,Normal,139,N,0.2,Up,0
643
+ 46,F,ASY,138,243,0,LVH,152,Y,0,Flat,0
644
+ 51,F,NAP,120,295,0,LVH,157,N,0.6,Up,0
645
+ 58,M,NAP,112,230,0,LVH,165,N,2.5,Flat,1
646
+ 71,F,NAP,110,265,1,LVH,130,N,0,Up,0
647
+ 57,M,NAP,128,229,0,LVH,150,N,0.4,Flat,1
648
+ 66,M,ASY,160,228,0,LVH,138,N,2.3,Up,0
649
+ 37,F,NAP,120,215,0,Normal,170,N,0,Up,0
650
+ 59,M,ASY,170,326,0,LVH,140,Y,3.4,Down,1
651
+ 50,M,ASY,144,200,0,LVH,126,Y,0.9,Flat,1
652
+ 48,M,ASY,130,256,1,LVH,150,Y,0,Up,1
653
+ 61,M,ASY,140,207,0,LVH,138,Y,1.9,Up,1
654
+ 59,M,TA,160,273,0,LVH,125,N,0,Up,1
655
+ 42,M,NAP,130,180,0,Normal,150,N,0,Up,0
656
+ 48,M,ASY,122,222,0,LVH,186,N,0,Up,0
657
+ 40,M,ASY,152,223,0,Normal,181,N,0,Up,1
658
+ 62,F,ASY,124,209,0,Normal,163,N,0,Up,0
659
+ 44,M,NAP,130,233,0,Normal,179,Y,0.4,Up,0
660
+ 46,M,ATA,101,197,1,Normal,156,N,0,Up,0
661
+ 59,M,NAP,126,218,1,Normal,134,N,2.2,Flat,1
662
+ 58,M,NAP,140,211,1,LVH,165,N,0,Up,0
663
+ 49,M,NAP,118,149,0,LVH,126,N,0.8,Up,1
664
+ 44,M,ASY,110,197,0,LVH,177,N,0,Up,1
665
+ 66,M,ATA,160,246,0,Normal,120,Y,0,Flat,1
666
+ 65,F,ASY,150,225,0,LVH,114,N,1,Flat,1
667
+ 42,M,ASY,136,315,0,Normal,125,Y,1.8,Flat,1
668
+ 52,M,ATA,128,205,1,Normal,184,N,0,Up,0
669
+ 65,F,NAP,140,417,1,LVH,157,N,0.8,Up,0
670
+ 63,F,ATA,140,195,0,Normal,179,N,0,Up,0
671
+ 45,F,ATA,130,234,0,LVH,175,N,0.6,Flat,0
672
+ 41,F,ATA,105,198,0,Normal,168,N,0,Up,0
673
+ 61,M,ASY,138,166,0,LVH,125,Y,3.6,Flat,1
674
+ 60,F,NAP,120,178,1,Normal,96,N,0,Up,0
675
+ 59,F,ASY,174,249,0,Normal,143,Y,0,Flat,1
676
+ 62,M,ATA,120,281,0,LVH,103,N,1.4,Flat,1
677
+ 57,M,NAP,150,126,1,Normal,173,N,0.2,Up,0
678
+ 51,F,ASY,130,305,0,Normal,142,Y,1.2,Flat,1
679
+ 44,M,NAP,120,226,0,Normal,169,N,0,Up,0
680
+ 60,F,TA,150,240,0,Normal,171,N,0.9,Up,0
681
+ 63,M,TA,145,233,1,LVH,150,N,2.3,Down,0
682
+ 57,M,ASY,150,276,0,LVH,112,Y,0.6,Flat,1
683
+ 51,M,ASY,140,261,0,LVH,186,Y,0,Up,0
684
+ 58,F,ATA,136,319,1,LVH,152,N,0,Up,1
685
+ 44,F,NAP,118,242,0,Normal,149,N,0.3,Flat,0
686
+ 47,M,NAP,108,243,0,Normal,152,N,0,Up,1
687
+ 61,M,ASY,120,260,0,Normal,140,Y,3.6,Flat,1
688
+ 57,F,ASY,120,354,0,Normal,163,Y,0.6,Up,0
689
+ 70,M,ATA,156,245,0,LVH,143,N,0,Up,0
690
+ 76,F,NAP,140,197,0,ST,116,N,1.1,Flat,0
691
+ 67,F,ASY,106,223,0,Normal,142,N,0.3,Up,0
692
+ 45,M,ASY,142,309,0,LVH,147,Y,0,Flat,1
693
+ 45,M,ASY,104,208,0,LVH,148,Y,3,Flat,0
694
+ 39,F,NAP,94,199,0,Normal,179,N,0,Up,0
695
+ 42,F,NAP,120,209,0,Normal,173,N,0,Flat,0
696
+ 56,M,ATA,120,236,0,Normal,178,N,0.8,Up,0
697
+ 58,M,ASY,146,218,0,Normal,105,N,2,Flat,1
698
+ 35,M,ASY,120,198,0,Normal,130,Y,1.6,Flat,1
699
+ 58,M,ASY,150,270,0,LVH,111,Y,0.8,Up,1
700
+ 41,M,NAP,130,214,0,LVH,168,N,2,Flat,0
701
+ 57,M,ASY,110,201,0,Normal,126,Y,1.5,Flat,0
702
+ 42,M,TA,148,244,0,LVH,178,N,0.8,Up,0
703
+ 62,M,ATA,128,208,1,LVH,140,N,0,Up,0
704
+ 59,M,TA,178,270,0,LVH,145,N,4.2,Down,0
705
+ 41,F,ATA,126,306,0,Normal,163,N,0,Up,0
706
+ 50,M,ASY,150,243,0,LVH,128,N,2.6,Flat,1
707
+ 59,M,ATA,140,221,0,Normal,164,Y,0,Up,0
708
+ 61,F,ASY,130,330,0,LVH,169,N,0,Up,1
709
+ 54,M,ASY,124,266,0,LVH,109,Y,2.2,Flat,1
710
+ 54,M,ASY,110,206,0,LVH,108,Y,0,Flat,1
711
+ 52,M,ASY,125,212,0,Normal,168,N,1,Up,1
712
+ 47,M,ASY,110,275,0,LVH,118,Y,1,Flat,1
713
+ 66,M,ASY,120,302,0,LVH,151,N,0.4,Flat,0
714
+ 58,M,ASY,100,234,0,Normal,156,N,0.1,Up,1
715
+ 64,F,NAP,140,313,0,Normal,133,N,0.2,Up,0
716
+ 50,F,ATA,120,244,0,Normal,162,N,1.1,Up,0
717
+ 44,F,NAP,108,141,0,Normal,175,N,0.6,Flat,0
718
+ 67,M,ASY,120,237,0,Normal,71,N,1,Flat,1
719
+ 49,F,ASY,130,269,0,Normal,163,N,0,Up,0
720
+ 57,M,ASY,165,289,1,LVH,124,N,1,Flat,1
721
+ 63,M,ASY,130,254,0,LVH,147,N,1.4,Flat,1
722
+ 48,M,ASY,124,274,0,LVH,166,N,0.5,Flat,1
723
+ 51,M,NAP,100,222,0,Normal,143,Y,1.2,Flat,0
724
+ 60,F,ASY,150,258,0,LVH,157,N,2.6,Flat,1
725
+ 59,M,ASY,140,177,0,Normal,162,Y,0,Up,1
726
+ 45,F,ATA,112,160,0,Normal,138,N,0,Flat,0
727
+ 55,F,ASY,180,327,0,ST,117,Y,3.4,Flat,1
728
+ 41,M,ATA,110,235,0,Normal,153,N,0,Up,0
729
+ 60,F,ASY,158,305,0,LVH,161,N,0,Up,1
730
+ 54,F,NAP,135,304,1,Normal,170,N,0,Up,0
731
+ 42,M,ATA,120,295,0,Normal,162,N,0,Up,0
732
+ 49,F,ATA,134,271,0,Normal,162,N,0,Flat,0
733
+ 46,M,ASY,120,249,0,LVH,144,N,0.8,Up,1
734
+ 56,F,ASY,200,288,1,LVH,133,Y,4,Down,1
735
+ 66,F,TA,150,226,0,Normal,114,N,2.6,Down,0
736
+ 56,M,ASY,130,283,1,LVH,103,Y,1.6,Down,1
737
+ 49,M,NAP,120,188,0,Normal,139,N,2,Flat,1
738
+ 54,M,ASY,122,286,0,LVH,116,Y,3.2,Flat,1
739
+ 57,M,ASY,152,274,0,Normal,88,Y,1.2,Flat,1
740
+ 65,F,NAP,160,360,0,LVH,151,N,0.8,Up,0
741
+ 54,M,NAP,125,273,0,LVH,152,N,0.5,Down,0
742
+ 54,F,NAP,160,201,0,Normal,163,N,0,Up,0
743
+ 62,M,ASY,120,267,0,Normal,99,Y,1.8,Flat,1
744
+ 52,F,NAP,136,196,0,LVH,169,N,0.1,Flat,0
745
+ 52,M,ATA,134,201,0,Normal,158,N,0.8,Up,0
746
+ 60,M,ASY,117,230,1,Normal,160,Y,1.4,Up,1
747
+ 63,F,ASY,108,269,0,Normal,169,Y,1.8,Flat,1
748
+ 66,M,ASY,112,212,0,LVH,132,Y,0.1,Up,1
749
+ 42,M,ASY,140,226,0,Normal,178,N,0,Up,0
750
+ 64,M,ASY,120,246,0,LVH,96,Y,2.2,Down,1
751
+ 54,M,NAP,150,232,0,LVH,165,N,1.6,Up,0
752
+ 46,F,NAP,142,177,0,LVH,160,Y,1.4,Down,0
753
+ 67,F,NAP,152,277,0,Normal,172,N,0,Up,0
754
+ 56,M,ASY,125,249,1,LVH,144,Y,1.2,Flat,1
755
+ 34,F,ATA,118,210,0,Normal,192,N,0.7,Up,0
756
+ 57,M,ASY,132,207,0,Normal,168,Y,0,Up,0
757
+ 64,M,ASY,145,212,0,LVH,132,N,2,Flat,1
758
+ 59,M,ASY,138,271,0,LVH,182,N,0,Up,0
759
+ 50,M,NAP,140,233,0,Normal,163,N,0.6,Flat,1
760
+ 51,M,TA,125,213,0,LVH,125,Y,1.4,Up,0
761
+ 54,M,ATA,192,283,0,LVH,195,N,0,Up,1
762
+ 53,M,ASY,123,282,0,Normal,95,Y,2,Flat,1
763
+ 52,M,ASY,112,230,0,Normal,160,N,0,Up,1
764
+ 40,M,ASY,110,167,0,LVH,114,Y,2,Flat,1
765
+ 58,M,NAP,132,224,0,LVH,173,N,3.2,Up,1
766
+ 41,F,NAP,112,268,0,LVH,172,Y,0,Up,0
767
+ 41,M,NAP,112,250,0,Normal,179,N,0,Up,0
768
+ 50,F,NAP,120,219,0,Normal,158,N,1.6,Flat,0
769
+ 54,F,NAP,108,267,0,LVH,167,N,0,Up,0
770
+ 64,F,ASY,130,303,0,Normal,122,N,2,Flat,0
771
+ 51,F,NAP,130,256,0,LVH,149,N,0.5,Up,0
772
+ 46,F,ATA,105,204,0,Normal,172,N,0,Up,0
773
+ 55,M,ASY,140,217,0,Normal,111,Y,5.6,Down,1
774
+ 45,M,ATA,128,308,0,LVH,170,N,0,Up,0
775
+ 56,M,TA,120,193,0,LVH,162,N,1.9,Flat,0
776
+ 66,F,ASY,178,228,1,Normal,165,Y,1,Flat,1
777
+ 38,M,TA,120,231,0,Normal,182,Y,3.8,Flat,1
778
+ 62,F,ASY,150,244,0,Normal,154,Y,1.4,Flat,1
779
+ 55,M,ATA,130,262,0,Normal,155,N,0,Up,0
780
+ 58,M,ASY,128,259,0,LVH,130,Y,3,Flat,1
781
+ 43,M,ASY,110,211,0,Normal,161,N,0,Up,0
782
+ 64,F,ASY,180,325,0,Normal,154,Y,0,Up,0
783
+ 50,F,ASY,110,254,0,LVH,159,N,0,Up,0
784
+ 53,M,NAP,130,197,1,LVH,152,N,1.2,Down,0
785
+ 45,F,ASY,138,236,0,LVH,152,Y,0.2,Flat,0
786
+ 65,M,TA,138,282,1,LVH,174,N,1.4,Flat,1
787
+ 69,M,TA,160,234,1,LVH,131,N,0.1,Flat,0
788
+ 69,M,NAP,140,254,0,LVH,146,N,2,Flat,1
789
+ 67,M,ASY,100,299,0,LVH,125,Y,0.9,Flat,1
790
+ 68,F,NAP,120,211,0,LVH,115,N,1.5,Flat,0
791
+ 34,M,TA,118,182,0,LVH,174,N,0,Up,0
792
+ 62,F,ASY,138,294,1,Normal,106,N,1.9,Flat,1
793
+ 51,M,ASY,140,298,0,Normal,122,Y,4.2,Flat,1
794
+ 46,M,NAP,150,231,0,Normal,147,N,3.6,Flat,1
795
+ 67,M,ASY,125,254,1,Normal,163,N,0.2,Flat,1
796
+ 50,M,NAP,129,196,0,Normal,163,N,0,Up,0
797
+ 42,M,NAP,120,240,1,Normal,194,N,0.8,Down,0
798
+ 56,F,ASY,134,409,0,LVH,150,Y,1.9,Flat,1
799
+ 41,M,ASY,110,172,0,LVH,158,N,0,Up,1
800
+ 42,F,ASY,102,265,0,LVH,122,N,0.6,Flat,0
801
+ 53,M,NAP,130,246,1,LVH,173,N,0,Up,0
802
+ 43,M,NAP,130,315,0,Normal,162,N,1.9,Up,0
803
+ 56,M,ASY,132,184,0,LVH,105,Y,2.1,Flat,1
804
+ 52,M,ASY,108,233,1,Normal,147,N,0.1,Up,0
805
+ 62,F,ASY,140,394,0,LVH,157,N,1.2,Flat,0
806
+ 70,M,NAP,160,269,0,Normal,112,Y,2.9,Flat,1
807
+ 54,M,ASY,140,239,0,Normal,160,N,1.2,Up,0
808
+ 70,M,ASY,145,174,0,Normal,125,Y,2.6,Down,1
809
+ 54,M,ATA,108,309,0,Normal,156,N,0,Up,0
810
+ 35,M,ASY,126,282,0,LVH,156,Y,0,Up,1
811
+ 48,M,NAP,124,255,1,Normal,175,N,0,Up,0
812
+ 55,F,ATA,135,250,0,LVH,161,N,1.4,Flat,0
813
+ 58,F,ASY,100,248,0,LVH,122,N,1,Flat,0
814
+ 54,F,NAP,110,214,0,Normal,158,N,1.6,Flat,0
815
+ 69,F,TA,140,239,0,Normal,151,N,1.8,Up,0
816
+ 77,M,ASY,125,304,0,LVH,162,Y,0,Up,1
817
+ 68,M,NAP,118,277,0,Normal,151,N,1,Up,0
818
+ 58,M,ASY,125,300,0,LVH,171,N,0,Up,1
819
+ 60,M,ASY,125,258,0,LVH,141,Y,2.8,Flat,1
820
+ 51,M,ASY,140,299,0,Normal,173,Y,1.6,Up,1
821
+ 55,M,ASY,160,289,0,LVH,145,Y,0.8,Flat,1
822
+ 52,M,TA,152,298,1,Normal,178,N,1.2,Flat,0
823
+ 60,F,NAP,102,318,0,Normal,160,N,0,Up,0
824
+ 58,M,NAP,105,240,0,LVH,154,Y,0.6,Flat,0
825
+ 64,M,NAP,125,309,0,Normal,131,Y,1.8,Flat,1
826
+ 37,M,NAP,130,250,0,Normal,187,N,3.5,Down,0
827
+ 59,M,TA,170,288,0,LVH,159,N,0.2,Flat,1
828
+ 51,M,NAP,125,245,1,LVH,166,N,2.4,Flat,0
829
+ 43,F,NAP,122,213,0,Normal,165,N,0.2,Flat,0
830
+ 58,M,ASY,128,216,0,LVH,131,Y,2.2,Flat,1
831
+ 29,M,ATA,130,204,0,LVH,202,N,0,Up,0
832
+ 41,F,ATA,130,204,0,LVH,172,N,1.4,Up,0
833
+ 63,F,NAP,135,252,0,LVH,172,N,0,Up,0
834
+ 51,M,NAP,94,227,0,Normal,154,Y,0,Up,0
835
+ 54,M,NAP,120,258,0,LVH,147,N,0.4,Flat,0
836
+ 44,M,ATA,120,220,0,Normal,170,N,0,Up,0
837
+ 54,M,ASY,110,239,0,Normal,126,Y,2.8,Flat,1
838
+ 65,M,ASY,135,254,0,LVH,127,N,2.8,Flat,1
839
+ 57,M,NAP,150,168,0,Normal,174,N,1.6,Up,0
840
+ 63,M,ASY,130,330,1,LVH,132,Y,1.8,Up,1
841
+ 35,F,ASY,138,183,0,Normal,182,N,1.4,Up,0
842
+ 41,M,ATA,135,203,0,Normal,132,N,0,Flat,0
843
+ 62,F,NAP,130,263,0,Normal,97,N,1.2,Flat,1
844
+ 43,F,ASY,132,341,1,LVH,136,Y,3,Flat,1
845
+ 58,F,TA,150,283,1,LVH,162,N,1,Up,0
846
+ 52,M,TA,118,186,0,LVH,190,N,0,Flat,0
847
+ 61,F,ASY,145,307,0,LVH,146,Y,1,Flat,1
848
+ 39,M,ASY,118,219,0,Normal,140,N,1.2,Flat,1
849
+ 45,M,ASY,115,260,0,LVH,185,N,0,Up,0
850
+ 52,M,ASY,128,255,0,Normal,161,Y,0,Up,1
851
+ 62,M,NAP,130,231,0,Normal,146,N,1.8,Flat,0
852
+ 62,F,ASY,160,164,0,LVH,145,N,6.2,Down,1
853
+ 53,F,ASY,138,234,0,LVH,160,N,0,Up,0
854
+ 43,M,ASY,120,177,0,LVH,120,Y,2.5,Flat,1
855
+ 47,M,NAP,138,257,0,LVH,156,N,0,Up,0
856
+ 52,M,ATA,120,325,0,Normal,172,N,0.2,Up,0
857
+ 68,M,NAP,180,274,1,LVH,150,Y,1.6,Flat,1
858
+ 39,M,NAP,140,321,0,LVH,182,N,0,Up,0
859
+ 53,F,ASY,130,264,0,LVH,143,N,0.4,Flat,0
860
+ 62,F,ASY,140,268,0,LVH,160,N,3.6,Down,1
861
+ 51,F,NAP,140,308,0,LVH,142,N,1.5,Up,0
862
+ 60,M,ASY,130,253,0,Normal,144,Y,1.4,Up,1
863
+ 65,M,ASY,110,248,0,LVH,158,N,0.6,Up,1
864
+ 65,F,NAP,155,269,0,Normal,148,N,0.8,Up,0
865
+ 60,M,NAP,140,185,0,LVH,155,N,3,Flat,1
866
+ 60,M,ASY,145,282,0,LVH,142,Y,2.8,Flat,1
867
+ 54,M,ASY,120,188,0,Normal,113,N,1.4,Flat,1
868
+ 44,M,ATA,130,219,0,LVH,188,N,0,Up,0
869
+ 44,M,ASY,112,290,0,LVH,153,N,0,Up,1
870
+ 51,M,NAP,110,175,0,Normal,123,N,0.6,Up,0
871
+ 59,M,NAP,150,212,1,Normal,157,N,1.6,Up,0
872
+ 71,F,ATA,160,302,0,Normal,162,N,0.4,Up,0
873
+ 61,M,NAP,150,243,1,Normal,137,Y,1,Flat,0
874
+ 55,M,ASY,132,353,0,Normal,132,Y,1.2,Flat,1
875
+ 64,M,NAP,140,335,0,Normal,158,N,0,Up,1
876
+ 43,M,ASY,150,247,0,Normal,171,N,1.5,Up,0
877
+ 58,F,NAP,120,340,0,Normal,172,N,0,Up,0
878
+ 60,M,ASY,130,206,0,LVH,132,Y,2.4,Flat,1
879
+ 58,M,ATA,120,284,0,LVH,160,N,1.8,Flat,1
880
+ 49,M,ATA,130,266,0,Normal,171,N,0.6,Up,0
881
+ 48,M,ATA,110,229,0,Normal,168,N,1,Down,1
882
+ 52,M,NAP,172,199,1,Normal,162,N,0.5,Up,0
883
+ 44,M,ATA,120,263,0,Normal,173,N,0,Up,0
884
+ 56,F,ATA,140,294,0,LVH,153,N,1.3,Flat,0
885
+ 57,M,ASY,140,192,0,Normal,148,N,0.4,Flat,0
886
+ 67,M,ASY,160,286,0,LVH,108,Y,1.5,Flat,1
887
+ 53,F,NAP,128,216,0,LVH,115,N,0,Up,0
888
+ 52,M,NAP,138,223,0,Normal,169,N,0,Up,0
889
+ 43,M,ASY,132,247,1,LVH,143,Y,0.1,Flat,1
890
+ 52,M,ASY,128,204,1,Normal,156,Y,1,Flat,1
891
+ 59,M,TA,134,204,0,Normal,162,N,0.8,Up,1
892
+ 64,M,TA,170,227,0,LVH,155,N,0.6,Flat,0
893
+ 66,F,NAP,146,278,0,LVH,152,N,0,Flat,0
894
+ 39,F,NAP,138,220,0,Normal,152,N,0,Flat,0
895
+ 57,M,ATA,154,232,0,LVH,164,N,0,Up,1
896
+ 58,F,ASY,130,197,0,Normal,131,N,0.6,Flat,0
897
+ 57,M,ASY,110,335,0,Normal,143,Y,3,Flat,1
898
+ 47,M,NAP,130,253,0,Normal,179,N,0,Up,0
899
+ 55,F,ASY,128,205,0,ST,130,Y,2,Flat,1
900
+ 35,M,ATA,122,192,0,Normal,174,N,0,Up,0
901
+ 61,M,ASY,148,203,0,Normal,161,N,0,Up,1
902
+ 58,M,ASY,114,318,0,ST,140,N,4.4,Down,1
903
+ 58,F,ASY,170,225,1,LVH,146,Y,2.8,Flat,1
904
+ 58,M,ATA,125,220,0,Normal,144,N,0.4,Flat,0
905
+ 56,M,ATA,130,221,0,LVH,163,N,0,Up,0
906
+ 56,M,ATA,120,240,0,Normal,169,N,0,Down,0
907
+ 67,M,NAP,152,212,0,LVH,150,N,0.8,Flat,1
908
+ 55,F,ATA,132,342,0,Normal,166,N,1.2,Up,0
909
+ 44,M,ASY,120,169,0,Normal,144,Y,2.8,Down,1
910
+ 63,M,ASY,140,187,0,LVH,144,Y,4,Up,1
911
+ 63,F,ASY,124,197,0,Normal,136,Y,0,Flat,1
912
+ 41,M,ATA,120,157,0,Normal,182,N,0,Up,0
913
+ 59,M,ASY,164,176,1,LVH,90,N,1,Flat,1
914
+ 57,F,ASY,140,241,0,Normal,123,Y,0.2,Flat,1
915
+ 45,M,TA,110,264,0,Normal,132,N,1.2,Flat,1
916
+ 68,M,ASY,144,193,1,Normal,141,N,3.4,Flat,1
917
+ 57,M,ASY,130,131,0,Normal,115,Y,1.2,Flat,1
918
+ 57,F,ATA,130,236,0,LVH,174,N,0,Flat,1
919
+ 38,M,NAP,138,175,0,Normal,173,N,0,Up,0
docker/entrypoint.sh ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Boots FastAPI (8000) + Next.js standalone (7860) in one container.
3
+ # Forwards signals so the container stops cleanly under HF Spaces / docker stop.
4
+ set -euo pipefail
5
+
6
+ cd /home/app/code
7
+
8
+ PORT_API="${PORT_API:-8000}"
9
+ PORT_WEB="${PORT:-7860}"
10
+ HOST="${HOSTNAME:-0.0.0.0}"
11
+
12
+ echo "[boot] starting FastAPI on :${PORT_API} ..."
13
+ uv run uvicorn api.main:app --host 127.0.0.1 --port "${PORT_API}" --workers 1 &
14
+ PID_API=$!
15
+
16
+ # Wait for FastAPI to become ready (max ~30s)
17
+ for i in $(seq 1 30); do
18
+ if curl -fsS "http://127.0.0.1:${PORT_API}/health" >/dev/null 2>&1; then
19
+ echo "[boot] FastAPI is up."
20
+ break
21
+ fi
22
+ sleep 1
23
+ done
24
+
25
+ echo "[boot] starting Next.js on :${PORT_WEB} ..."
26
+ cd /home/app/code/frontend
27
+ HOSTNAME="${HOST}" PORT="${PORT_WEB}" node server.js &
28
+ PID_WEB=$!
29
+
30
+ terminate() {
31
+ echo "[boot] stopping ..."
32
+ kill -TERM "${PID_API}" "${PID_WEB}" 2>/dev/null || true
33
+ wait || true
34
+ exit 0
35
+ }
36
+ trap terminate INT TERM
37
+
38
+ # Exit if either child dies
39
+ wait -n "${PID_API}" "${PID_WEB}"
40
+ EXIT_CODE=$?
41
+ echo "[boot] a child exited with code ${EXIT_CODE}, shutting down."
42
+ terminate
frontend/.gitignore ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # env files (can opt-in for committing if needed)
34
+ .env*
35
+
36
+ # vercel
37
+ .vercel
38
+
39
+ # typescript
40
+ *.tsbuildinfo
41
+ next-env.d.ts
frontend/README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
2
+
3
+ ## Getting Started
4
+
5
+ First, run the development server:
6
+
7
+ ```bash
8
+ npm run dev
9
+ # or
10
+ yarn dev
11
+ # or
12
+ pnpm dev
13
+ # or
14
+ bun dev
15
+ ```
16
+
17
+ Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18
+
19
+ You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20
+
21
+ This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22
+
23
+ ## Learn More
24
+
25
+ To learn more about Next.js, take a look at the following resources:
26
+
27
+ - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28
+ - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29
+
30
+ You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31
+
32
+ ## Deploy on Vercel
33
+
34
+ The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35
+
36
+ Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
frontend/eslint.config.mjs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
frontend/next.config.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ output: "standalone",
5
+ async rewrites() {
6
+ const apiTarget = process.env.API_INTERNAL_URL ?? "http://127.0.0.1:8000";
7
+ return [
8
+ {
9
+ source: "/api/:path*",
10
+ destination: `${apiTarget}/:path*`,
11
+ },
12
+ ];
13
+ },
14
+ };
15
+
16
+ export default nextConfig;
frontend/package.json ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint"
10
+ },
11
+ "dependencies": {
12
+ "@hookform/resolvers": "^5.4.0",
13
+ "@radix-ui/react-dialog": "^1.1.16",
14
+ "@radix-ui/react-dropdown-menu": "^2.1.17",
15
+ "@radix-ui/react-label": "^2.1.9",
16
+ "@radix-ui/react-select": "^2.3.0",
17
+ "@radix-ui/react-slot": "^1.2.5",
18
+ "@radix-ui/react-toast": "^1.2.16",
19
+ "@tanstack/react-query": "^5.101.0",
20
+ "@tanstack/react-table": "^8.21.3",
21
+ "class-variance-authority": "^0.7.1",
22
+ "clsx": "^2.1.1",
23
+ "lucide-react": "^1.17.0",
24
+ "next": "16.2.7",
25
+ "next-intl": "^4.13.0",
26
+ "next-themes": "^0.4.6",
27
+ "react": "19.2.4",
28
+ "react-dom": "19.2.4",
29
+ "react-hook-form": "^7.78.0",
30
+ "recharts": "^3.8.1",
31
+ "tailwind-merge": "^3.6.0",
32
+ "zod": "^4.4.3"
33
+ },
34
+ "devDependencies": {
35
+ "@tailwindcss/postcss": "^4",
36
+ "@types/node": "^20",
37
+ "@types/react": "^19",
38
+ "@types/react-dom": "^19",
39
+ "eslint": "^9",
40
+ "eslint-config-next": "16.2.7",
41
+ "tailwindcss": "^4",
42
+ "typescript": "^5"
43
+ }
44
+ }
frontend/pnpm-lock.yaml ADDED
The diff for this file is too large to render. See raw diff
 
frontend/pnpm-workspace.yaml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ ignoredBuiltDependencies:
2
+ - sharp
3
+ - unrs-resolver
frontend/postcss.config.mjs ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
frontend/public/file.svg ADDED
frontend/public/globe.svg ADDED
frontend/public/logu1.png ADDED

Git LFS Details

  • SHA256: 3c519c9b36504e12c4e144d92680462820525603c5619f8ff03a25c897d893e5
  • Pointer size: 131 Bytes
  • Size of remote file: 632 kB
frontend/public/next.svg ADDED
frontend/public/vercel.svg ADDED
frontend/public/window.svg ADDED
frontend/src/app/[lang]/eda/page.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { notFound } from "next/navigation";
2
+ import { isLocale } from "@/i18n/config";
3
+ import { getDictionary } from "@/i18n/dictionaries";
4
+ import { PageHeader } from "@/components/layout/page-header";
5
+ import { EdaCharts } from "@/components/charts/eda-charts";
6
+
7
+ export default async function Page({
8
+ params,
9
+ }: {
10
+ params: Promise<{ lang: string }>;
11
+ }) {
12
+ const { lang } = await params;
13
+ if (!isLocale(lang)) notFound();
14
+ const dict = await getDictionary(lang);
15
+ return (
16
+ <div className="space-y-6">
17
+ <PageHeader title={dict.eda.title} subtitle={dict.eda.subtitle} />
18
+ <EdaCharts />
19
+ </div>
20
+ );
21
+ }
frontend/src/app/[lang]/layout.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { notFound } from "next/navigation";
2
+ import { isLocale, locales } from "@/i18n/config";
3
+ import { getDictionary } from "@/i18n/dictionaries";
4
+ import { Providers } from "@/components/providers";
5
+ import { Sidebar } from "@/components/layout/sidebar";
6
+ import { Header } from "@/components/layout/header";
7
+
8
+ export async function generateStaticParams() {
9
+ return locales.map((lang) => ({ lang }));
10
+ }
11
+
12
+ export default async function LangLayout({
13
+ children,
14
+ params,
15
+ }: {
16
+ children: React.ReactNode;
17
+ params: Promise<{ lang: string }>;
18
+ }) {
19
+ const { lang } = await params;
20
+ if (!isLocale(lang)) notFound();
21
+ const dict = await getDictionary(lang);
22
+
23
+ return (
24
+ <Providers dict={dict} locale={lang}>
25
+ <div className="flex min-h-screen bg-background text-foreground">
26
+ <Sidebar />
27
+ <div className="flex flex-1 flex-col">
28
+ <Header />
29
+ <main className="flex-1 overflow-x-hidden p-6 lg:p-8">{children}</main>
30
+ </div>
31
+ </div>
32
+ </Providers>
33
+ );
34
+ }
frontend/src/app/[lang]/model/page.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { notFound } from "next/navigation";
2
+ import { isLocale } from "@/i18n/config";
3
+ import { getDictionary } from "@/i18n/dictionaries";
4
+ import { PageHeader } from "@/components/layout/page-header";
5
+ import { ModelInsights } from "@/components/model/model-insights";
6
+
7
+ export default async function Page({
8
+ params,
9
+ }: {
10
+ params: Promise<{ lang: string }>;
11
+ }) {
12
+ const { lang } = await params;
13
+ if (!isLocale(lang)) notFound();
14
+ const dict = await getDictionary(lang);
15
+ return (
16
+ <div className="space-y-6">
17
+ <PageHeader title={dict.model.title} subtitle={dict.model.subtitle} />
18
+ <ModelInsights />
19
+ </div>
20
+ );
21
+ }
frontend/src/app/[lang]/new-evaluation/page.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { EvaluationForm } from "@/components/forms/evaluation-form";
2
+ import { PageHeader } from "@/components/layout/page-header";
3
+ import { getDictionary } from "@/i18n/dictionaries";
4
+ import { isLocale } from "@/i18n/config";
5
+ import { notFound } from "next/navigation";
6
+
7
+ export default async function Page({
8
+ params,
9
+ }: {
10
+ params: Promise<{ lang: string }>;
11
+ }) {
12
+ const { lang } = await params;
13
+ if (!isLocale(lang)) notFound();
14
+ const dict = await getDictionary(lang);
15
+ return (
16
+ <div className="space-y-6">
17
+ <PageHeader title={dict.form.title} subtitle={dict.form.subtitle} />
18
+ <EvaluationForm />
19
+ </div>
20
+ );
21
+ }
frontend/src/app/[lang]/page.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { notFound } from "next/navigation";
2
+ import { isLocale } from "@/i18n/config";
3
+ import { getDictionary } from "@/i18n/dictionaries";
4
+ import { PageHeader } from "@/components/layout/page-header";
5
+ import { DashboardClient } from "@/components/dashboard/dashboard-client";
6
+
7
+ export default async function Page({
8
+ params,
9
+ }: {
10
+ params: Promise<{ lang: string }>;
11
+ }) {
12
+ const { lang } = await params;
13
+ if (!isLocale(lang)) notFound();
14
+ const dict = await getDictionary(lang);
15
+ return (
16
+ <div className="space-y-6">
17
+ <PageHeader title={dict.dashboard.title} subtitle={dict.dashboard.subtitle} />
18
+ <DashboardClient />
19
+ </div>
20
+ );
21
+ }
frontend/src/app/[lang]/patients/page.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { notFound } from "next/navigation";
2
+ import { isLocale } from "@/i18n/config";
3
+ import { getDictionary } from "@/i18n/dictionaries";
4
+ import { PageHeader } from "@/components/layout/page-header";
5
+ import { PatientsTable } from "@/components/patients/patients-table";
6
+
7
+ export default async function Page({
8
+ params,
9
+ }: {
10
+ params: Promise<{ lang: string }>;
11
+ }) {
12
+ const { lang } = await params;
13
+ if (!isLocale(lang)) notFound();
14
+ const dict = await getDictionary(lang);
15
+ return (
16
+ <div className="space-y-6">
17
+ <PageHeader title={dict.history.title} subtitle={dict.history.subtitle} />
18
+ <PatientsTable />
19
+ </div>
20
+ );
21
+ }
frontend/src/app/[lang]/settings/page.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { notFound } from "next/navigation";
2
+ import { isLocale } from "@/i18n/config";
3
+ import { getDictionary } from "@/i18n/dictionaries";
4
+ import { PageHeader } from "@/components/layout/page-header";
5
+ import { SettingsPanel } from "@/components/settings/settings-panel";
6
+
7
+ export default async function Page({
8
+ params,
9
+ }: {
10
+ params: Promise<{ lang: string }>;
11
+ }) {
12
+ const { lang } = await params;
13
+ if (!isLocale(lang)) notFound();
14
+ const dict = await getDictionary(lang);
15
+ return (
16
+ <div className="space-y-6">
17
+ <PageHeader title={dict.settings.title} />
18
+ <SettingsPanel />
19
+ </div>
20
+ );
21
+ }
frontend/src/app/favicon.ico ADDED
frontend/src/app/globals.css ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ @custom-variant dark (&:where(.dark, .dark *));
4
+
5
+ @theme inline {
6
+ --color-background: var(--background);
7
+ --color-foreground: var(--foreground);
8
+ --color-card: var(--card);
9
+ --color-card-foreground: var(--card-foreground);
10
+ --color-popover: var(--popover);
11
+ --color-popover-foreground: var(--popover-foreground);
12
+ --color-primary: var(--primary);
13
+ --color-primary-foreground: var(--primary-foreground);
14
+ --color-secondary: var(--secondary);
15
+ --color-secondary-foreground: var(--secondary-foreground);
16
+ --color-muted: var(--muted);
17
+ --color-muted-foreground: var(--muted-foreground);
18
+ --color-accent: var(--accent);
19
+ --color-accent-foreground: var(--accent-foreground);
20
+ --color-destructive: var(--destructive);
21
+ --color-destructive-foreground: var(--destructive-foreground);
22
+ --color-success: var(--success);
23
+ --color-success-foreground: var(--success-foreground);
24
+ --color-warning: var(--warning);
25
+ --color-warning-foreground: var(--warning-foreground);
26
+ --color-border: var(--border);
27
+ --color-input: var(--input);
28
+ --color-ring: var(--ring);
29
+ --color-sidebar: var(--sidebar);
30
+ --color-sidebar-foreground: var(--sidebar-foreground);
31
+ --color-sidebar-border: var(--sidebar-border);
32
+ --color-sidebar-accent: var(--sidebar-accent);
33
+ --font-sans: var(--font-inter);
34
+ --font-mono: var(--font-jetbrains-mono);
35
+ --radius-sm: 0.25rem;
36
+ --radius-md: 0.375rem;
37
+ --radius-lg: 0.5rem;
38
+ --radius-xl: 0.75rem;
39
+ }
40
+
41
+ :root {
42
+ --background: #f7f8fa;
43
+ --foreground: #0b1220;
44
+ --card: #ffffff;
45
+ --card-foreground: #0b1220;
46
+ --popover: #ffffff;
47
+ --popover-foreground: #0b1220;
48
+ --primary: #1d4ed8;
49
+ --primary-foreground: #ffffff;
50
+ --secondary: #eef2ff;
51
+ --secondary-foreground: #1e293b;
52
+ --muted: #f1f5f9;
53
+ --muted-foreground: #64748b;
54
+ --accent: #e0f2fe;
55
+ --accent-foreground: #075985;
56
+ --destructive: #dc2626;
57
+ --destructive-foreground: #ffffff;
58
+ --success: #15803d;
59
+ --success-foreground: #ffffff;
60
+ --warning: #b45309;
61
+ --warning-foreground: #ffffff;
62
+ --border: #e2e8f0;
63
+ --input: #e2e8f0;
64
+ --ring: #1d4ed8;
65
+ --sidebar: #0f172a;
66
+ --sidebar-foreground: #cbd5e1;
67
+ --sidebar-border: #1e293b;
68
+ --sidebar-accent: #1e293b;
69
+ }
70
+
71
+ .dark {
72
+ --background: #0a0f1a;
73
+ --foreground: #e6edf7;
74
+ --card: #0f172a;
75
+ --card-foreground: #e6edf7;
76
+ --popover: #0f172a;
77
+ --popover-foreground: #e6edf7;
78
+ --primary: #60a5fa;
79
+ --primary-foreground: #0a0f1a;
80
+ --secondary: #1e293b;
81
+ --secondary-foreground: #e6edf7;
82
+ --muted: #1e293b;
83
+ --muted-foreground: #94a3b8;
84
+ --accent: #1e3a8a;
85
+ --accent-foreground: #dbeafe;
86
+ --destructive: #f87171;
87
+ --destructive-foreground: #0a0f1a;
88
+ --success: #34d399;
89
+ --success-foreground: #0a0f1a;
90
+ --warning: #fbbf24;
91
+ --warning-foreground: #0a0f1a;
92
+ --border: #1e293b;
93
+ --input: #1e293b;
94
+ --ring: #60a5fa;
95
+ --sidebar: #060a13;
96
+ --sidebar-foreground: #cbd5e1;
97
+ --sidebar-border: #1e293b;
98
+ --sidebar-accent: #1e293b;
99
+ }
100
+
101
+ * {
102
+ border-color: var(--border);
103
+ }
104
+
105
+ html,
106
+ body {
107
+ background: var(--background);
108
+ color: var(--foreground);
109
+ font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
110
+ -webkit-font-smoothing: antialiased;
111
+ }
112
+
113
+ ::-webkit-scrollbar {
114
+ width: 10px;
115
+ height: 10px;
116
+ }
117
+ ::-webkit-scrollbar-track {
118
+ background: transparent;
119
+ }
120
+ ::-webkit-scrollbar-thumb {
121
+ background: var(--border);
122
+ border-radius: 9999px;
123
+ }
124
+ ::-webkit-scrollbar-thumb:hover {
125
+ background: var(--muted-foreground);
126
+ }
127
+
128
+ /* Ticket print styles */
129
+ @media print {
130
+ body * {
131
+ visibility: hidden;
132
+ }
133
+ .print-area,
134
+ .print-area * {
135
+ visibility: visible;
136
+ }
137
+ .print-area {
138
+ position: absolute;
139
+ left: 0;
140
+ top: 0;
141
+ width: 100%;
142
+ padding: 1rem;
143
+ background: #fff !important;
144
+ color: #000 !important;
145
+ }
146
+ }
frontend/src/app/layout.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Inter, JetBrains_Mono } from "next/font/google";
3
+ import { ThemeRoot } from "@/components/providers";
4
+ import "./globals.css";
5
+
6
+ const inter = Inter({
7
+ variable: "--font-inter",
8
+ subsets: ["latin"],
9
+ });
10
+
11
+ const mono = JetBrains_Mono({
12
+ variable: "--font-jetbrains-mono",
13
+ subsets: ["latin"],
14
+ });
15
+
16
+ export const metadata: Metadata = {
17
+ title: "BeatSense — Cardiovascular Risk Intelligence",
18
+ description:
19
+ "Clinical workstation for cardiovascular risk assessment powered by ML.",
20
+ icons: { icon: "/logu1.png" },
21
+ };
22
+
23
+ export default function RootLayout({
24
+ children,
25
+ }: {
26
+ children: React.ReactNode;
27
+ }) {
28
+ return (
29
+ <html suppressHydrationWarning>
30
+ <body
31
+ className={`${inter.variable} ${mono.variable} min-h-screen antialiased`}
32
+ >
33
+ <ThemeRoot>{children}</ThemeRoot>
34
+ </body>
35
+ </html>
36
+ );
37
+ }
frontend/src/components/charts/eda-charts.tsx ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import {
5
+ BarChart,
6
+ Bar,
7
+ XAxis,
8
+ YAxis,
9
+ Tooltip,
10
+ ResponsiveContainer,
11
+ CartesianGrid,
12
+ ScatterChart,
13
+ Scatter,
14
+ Legend,
15
+ Cell,
16
+ PieChart,
17
+ Pie,
18
+ } from "recharts";
19
+ import { api } from "@/lib/api";
20
+ import { useT } from "@/i18n/context";
21
+ import { Card, CardContent } from "@/components/ui/card";
22
+
23
+ const TOOLTIP = {
24
+ contentStyle: {
25
+ background: "var(--popover)",
26
+ border: "1px solid var(--border)",
27
+ borderRadius: 8,
28
+ fontSize: 12,
29
+ color: "var(--popover-foreground)",
30
+ },
31
+ cursor: { fill: "var(--muted)", opacity: 0.3 },
32
+ };
33
+
34
+ export function EdaCharts() {
35
+ const { dict } = useT();
36
+ const { data, isLoading } = useQuery({ queryKey: ["eda"], queryFn: api.eda });
37
+
38
+ if (isLoading || !data) {
39
+ return (
40
+ <Card>
41
+ <CardContent className="p-10 text-center text-sm text-muted-foreground">
42
+ {dict.common.loading}
43
+ </CardContent>
44
+ </Card>
45
+ );
46
+ }
47
+
48
+ return (
49
+ <div className="grid gap-6 lg:grid-cols-2">
50
+ <ChartCard title={dict.eda.ageDist}>
51
+ <ResponsiveContainer width="100%" height={280}>
52
+ <BarChart data={data.age_dist}>
53
+ <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
54
+ <XAxis dataKey="bin" tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
55
+ <YAxis tick={{ fontSize: 10 }} stroke="var(--muted-foreground)" />
56
+ <Tooltip {...TOOLTIP} />
57
+ <Bar dataKey="count" fill="var(--primary)" radius={[4, 4, 0, 0]} />
58
+ </BarChart>
59
+ </ResponsiveContainer>
60
+ </ChartCard>
61
+
62
+ <ChartCard title={dict.eda.sexDist}>
63
+ <ResponsiveContainer width="100%" height={280}>
64
+ <PieChart>
65
+ <Pie
66
+ data={data.sex_dist}
67
+ dataKey="count"
68
+ nameKey="sex"
69
+ innerRadius={50}
70
+ outerRadius={90}
71
+ label
72
+ >
73
+ {data.sex_dist.map((_, i) => (
74
+ <Cell key={i} fill={i === 0 ? "var(--primary)" : "var(--accent)"} />
75
+ ))}
76
+ </Pie>
77
+ <Tooltip {...TOOLTIP} />
78
+ <Legend wrapperStyle={{ fontSize: 12 }} />
79
+ </PieChart>
80
+ </ResponsiveContainer>
81
+ </ChartCard>
82
+
83
+ <ChartCard title={dict.eda.chestPainDist}>
84
+ <ResponsiveContainer width="100%" height={280}>
85
+ <BarChart data={data.chest_pain_dist}>
86
+ <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
87
+ <XAxis dataKey="type" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
88
+ <YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
89
+ <Tooltip {...TOOLTIP} />
90
+ <Bar dataKey="count" fill="var(--primary)" radius={[4, 4, 0, 0]} />
91
+ <Bar dataKey="risk_rate" fill="var(--destructive)" radius={[4, 4, 0, 0]} />
92
+ </BarChart>
93
+ </ResponsiveContainer>
94
+ </ChartCard>
95
+
96
+ <ChartCard title={dict.eda.maxHrVsAge}>
97
+ <ResponsiveContainer width="100%" height={280}>
98
+ <ScatterChart>
99
+ <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
100
+ <XAxis
101
+ dataKey="Age"
102
+ type="number"
103
+ tick={{ fontSize: 11 }}
104
+ stroke="var(--muted-foreground)"
105
+ name="Age"
106
+ />
107
+ <YAxis
108
+ dataKey="MaxHR"
109
+ type="number"
110
+ tick={{ fontSize: 11 }}
111
+ stroke="var(--muted-foreground)"
112
+ name="MaxHR"
113
+ />
114
+ <Tooltip {...TOOLTIP} />
115
+ <Scatter
116
+ data={data.scatter.filter((d) => d.HeartDisease === 0)}
117
+ fill="var(--success)"
118
+ name={dict.common.low}
119
+ />
120
+ <Scatter
121
+ data={data.scatter.filter((d) => d.HeartDisease === 1)}
122
+ fill="var(--destructive)"
123
+ name={dict.common.high}
124
+ />
125
+ <Legend wrapperStyle={{ fontSize: 12 }} />
126
+ </ScatterChart>
127
+ </ResponsiveContainer>
128
+ </ChartCard>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ function ChartCard({
134
+ title,
135
+ children,
136
+ }: {
137
+ title: string;
138
+ children: React.ReactNode;
139
+ }) {
140
+ return (
141
+ <Card>
142
+ <CardContent className="p-6">
143
+ <h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
144
+ {title}
145
+ </h2>
146
+ {children}
147
+ </CardContent>
148
+ </Card>
149
+ );
150
+ }
frontend/src/components/dashboard/dashboard-client.tsx ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import {
6
+ Activity,
7
+ AlertTriangle,
8
+ CalendarDays,
9
+ HeartPulse,
10
+ TrendingUp,
11
+ } from "lucide-react";
12
+ import {
13
+ PieChart,
14
+ Pie,
15
+ Cell,
16
+ ResponsiveContainer,
17
+ Legend,
18
+ Tooltip,
19
+ } from "recharts";
20
+ import { Card, CardContent } from "@/components/ui/card";
21
+ import { Badge } from "@/components/ui/badge";
22
+ import { Button } from "@/components/ui/button";
23
+ import { api } from "@/lib/api";
24
+ import { useT } from "@/i18n/context";
25
+
26
+ export function DashboardClient() {
27
+ const { dict, locale } = useT();
28
+ const stats = useQuery({ queryKey: ["stats"], queryFn: api.stats });
29
+ const history = useQuery({ queryKey: ["history"], queryFn: api.history });
30
+
31
+ const s = stats.data ?? {
32
+ total: 0,
33
+ high_risk: 0,
34
+ low_risk: 0,
35
+ high_risk_pct: 0,
36
+ today: 0,
37
+ };
38
+
39
+ const recent = (history.data ?? []).slice(-8).reverse();
40
+
41
+ const pieData = [
42
+ { name: dict.dashboard.kpi.lowRisk, value: s.low_risk, color: "var(--success)" },
43
+ { name: dict.dashboard.kpi.highRisk, value: s.high_risk, color: "var(--destructive)" },
44
+ ];
45
+
46
+ return (
47
+ <div className="grid gap-6">
48
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
49
+ <KpiCard
50
+ icon={<Activity className="h-4 w-4" />}
51
+ label={dict.dashboard.kpi.totalAssessments}
52
+ value={s.total.toString()}
53
+ />
54
+ <KpiCard
55
+ icon={<CalendarDays className="h-4 w-4" />}
56
+ label={dict.dashboard.kpi.todayAssessments}
57
+ value={s.today.toString()}
58
+ />
59
+ <KpiCard
60
+ icon={<AlertTriangle className="h-4 w-4" />}
61
+ label={dict.dashboard.kpi.highRiskPct}
62
+ value={`${s.high_risk_pct}%`}
63
+ tone={s.high_risk_pct >= 40 ? "danger" : "default"}
64
+ />
65
+ <KpiCard
66
+ icon={<TrendingUp className="h-4 w-4" />}
67
+ label={dict.dashboard.kpi.highRisk}
68
+ value={s.high_risk.toString()}
69
+ tone="danger"
70
+ />
71
+ </div>
72
+
73
+ <div className="grid gap-6 lg:grid-cols-3">
74
+ <Card className="lg:col-span-2">
75
+ <CardContent className="p-6">
76
+ <div className="mb-4 flex items-center justify-between">
77
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
78
+ {dict.dashboard.recentTitle}
79
+ </h2>
80
+ <Button asChild size="sm">
81
+ <Link href={`/${locale}/new-evaluation`}>
82
+ <HeartPulse className="h-4 w-4" />
83
+ {dict.dashboard.quickAction}
84
+ </Link>
85
+ </Button>
86
+ </div>
87
+ {recent.length === 0 ? (
88
+ <div className="rounded-md border border-dashed border-border py-10 text-center text-sm text-muted-foreground">
89
+ {dict.dashboard.recentEmpty}
90
+ </div>
91
+ ) : (
92
+ <div className="overflow-hidden rounded-md border border-border">
93
+ <table className="w-full text-sm">
94
+ <thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
95
+ <tr>
96
+ <th className="px-3 py-2 text-left">{dict.history.columns.patientId}</th>
97
+ <th className="px-3 py-2 text-left">{dict.history.columns.date}</th>
98
+ <th className="px-3 py-2 text-left">{dict.history.columns.age}</th>
99
+ <th className="px-3 py-2 text-left">{dict.history.columns.sex}</th>
100
+ <th className="px-3 py-2 text-right">{dict.history.columns.probability}</th>
101
+ <th className="px-3 py-2 text-right">{dict.history.columns.risk}</th>
102
+ </tr>
103
+ </thead>
104
+ <tbody>
105
+ {recent.map((r, i) => (
106
+ <tr
107
+ key={`${r.patient_id}-${i}`}
108
+ className="border-t border-border"
109
+ >
110
+ <td className="px-3 py-2 font-mono text-xs">{r.patient_id}</td>
111
+ <td className="px-3 py-2">{r.date} · {r.time}</td>
112
+ <td className="px-3 py-2">{r.age}</td>
113
+ <td className="px-3 py-2">{r.sex}</td>
114
+ <td className="px-3 py-2 text-right font-semibold">
115
+ {r.probability.toFixed(1)}%
116
+ </td>
117
+ <td className="px-3 py-2 text-right">
118
+ <Badge variant={r.risk === "ALTO" ? "destructive" : "success"}>
119
+ {r.risk === "ALTO"
120
+ ? dict.common.high
121
+ : dict.common.low}
122
+ </Badge>
123
+ </td>
124
+ </tr>
125
+ ))}
126
+ </tbody>
127
+ </table>
128
+ </div>
129
+ )}
130
+ </CardContent>
131
+ </Card>
132
+
133
+ <Card>
134
+ <CardContent className="p-6">
135
+ <h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
136
+ {dict.dashboard.riskDistribution}
137
+ </h2>
138
+ <div className="h-64">
139
+ {s.total === 0 ? (
140
+ <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
141
+ {dict.common.noData}
142
+ </div>
143
+ ) : (
144
+ <ResponsiveContainer width="100%" height="100%">
145
+ <PieChart>
146
+ <Pie
147
+ data={pieData}
148
+ dataKey="value"
149
+ nameKey="name"
150
+ innerRadius={50}
151
+ outerRadius={80}
152
+ paddingAngle={2}
153
+ >
154
+ {pieData.map((d, i) => (
155
+ <Cell key={i} fill={d.color} />
156
+ ))}
157
+ </Pie>
158
+ <Tooltip
159
+ contentStyle={{
160
+ background: "var(--popover)",
161
+ border: "1px solid var(--border)",
162
+ borderRadius: 8,
163
+ fontSize: 12,
164
+ }}
165
+ />
166
+ <Legend wrapperStyle={{ fontSize: 12 }} />
167
+ </PieChart>
168
+ </ResponsiveContainer>
169
+ )}
170
+ </div>
171
+ </CardContent>
172
+ </Card>
173
+ </div>
174
+ </div>
175
+ );
176
+ }
177
+
178
+ function KpiCard({
179
+ icon,
180
+ label,
181
+ value,
182
+ tone = "default",
183
+ }: {
184
+ icon: React.ReactNode;
185
+ label: string;
186
+ value: string;
187
+ tone?: "default" | "danger";
188
+ }) {
189
+ return (
190
+ <Card>
191
+ <CardContent className="flex items-center justify-between gap-4 p-5">
192
+ <div>
193
+ <div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
194
+ {label}
195
+ </div>
196
+ <div
197
+ className={`mt-1 text-3xl font-bold ${
198
+ tone === "danger" ? "text-destructive" : "text-foreground"
199
+ }`}
200
+ >
201
+ {value}
202
+ </div>
203
+ </div>
204
+ <div
205
+ className={`flex h-11 w-11 items-center justify-center rounded-lg ${
206
+ tone === "danger"
207
+ ? "bg-destructive/10 text-destructive"
208
+ : "bg-primary/10 text-primary"
209
+ }`}
210
+ >
211
+ {icon}
212
+ </div>
213
+ </CardContent>
214
+ </Card>
215
+ );
216
+ }
frontend/src/components/forms/evaluation-form.tsx ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useForm } from "react-hook-form";
4
+ import { zodResolver } from "@hookform/resolvers/zod";
5
+ import { z } from "zod";
6
+ import { useMutation } from "@tanstack/react-query";
7
+ import { useState } from "react";
8
+ import {
9
+ Heart,
10
+ Stethoscope,
11
+ ClipboardList,
12
+ Loader2,
13
+ RotateCcw,
14
+ Activity,
15
+ } from "lucide-react";
16
+ import { useT } from "@/i18n/context";
17
+ import { api, type PredictRequest, type PredictResponse } from "@/lib/api";
18
+ import { Button } from "@/components/ui/button";
19
+ import { Card, CardContent } from "@/components/ui/card";
20
+ import { Input } from "@/components/ui/input";
21
+ import { Label } from "@/components/ui/label";
22
+ import {
23
+ Select,
24
+ SelectContent,
25
+ SelectItem,
26
+ SelectTrigger,
27
+ SelectValue,
28
+ } from "@/components/ui/select";
29
+ import { Ticket } from "@/components/ticket/ticket";
30
+
31
+ const numberOrEmpty = z
32
+ .union([z.string(), z.number()])
33
+ .transform((v) => {
34
+ if (typeof v === "number") return v;
35
+ if (v === "" || v == null) return undefined;
36
+ const n = Number(v);
37
+ return Number.isFinite(n) ? n : undefined;
38
+ })
39
+ .optional();
40
+
41
+ const requiredNumber = z
42
+ .union([z.string(), z.number()])
43
+ .transform((v) => Number(v))
44
+ .refine((n) => Number.isFinite(n), { message: "Invalid number" });
45
+
46
+ const schema = z.object({
47
+ patient_id: z.string().max(64).optional(),
48
+ age: requiredNumber.refine((n) => n >= 1 && n <= 120, "Age out of range"),
49
+ sex: z.enum(["M", "F"]),
50
+ resting_bp: numberOrEmpty,
51
+ cholesterol: numberOrEmpty,
52
+ max_hr: requiredNumber.refine((n) => n >= 40 && n <= 240, "HR out of range"),
53
+ fasting_bs: z.enum(["0", "1"]),
54
+ exercise_angina: z.enum(["Y", "N"]),
55
+ chest_pain_type: z.enum(["ASY", "ATA", "NAP", "TA"]),
56
+ resting_ecg: z.enum(["Normal", "ST", "LVH"]).optional(),
57
+ st_slope: z.enum(["Up", "Flat", "Down"]),
58
+ oldpeak: numberOrEmpty,
59
+ });
60
+
61
+ type FormValues = z.input<typeof schema>;
62
+ type FormOutput = z.output<typeof schema>;
63
+
64
+ const DEFAULTS: Partial<FormValues> = {
65
+ patient_id: "",
66
+ sex: "M",
67
+ fasting_bs: "0",
68
+ exercise_angina: "N",
69
+ chest_pain_type: "ASY",
70
+ resting_ecg: "Normal",
71
+ st_slope: "Flat",
72
+ };
73
+
74
+ export function EvaluationForm() {
75
+ const { dict } = useT();
76
+ const [result, setResult] = useState<{
77
+ response: PredictResponse;
78
+ snapshot: FormOutput;
79
+ } | null>(null);
80
+
81
+ const {
82
+ register,
83
+ handleSubmit,
84
+ reset,
85
+ setValue,
86
+ watch,
87
+ formState: { errors },
88
+ } = useForm<FormValues, unknown, FormOutput>({
89
+ resolver: zodResolver(schema),
90
+ defaultValues: DEFAULTS,
91
+ });
92
+
93
+ const mutation = useMutation({
94
+ mutationFn: (values: PredictRequest) => api.predict(values),
95
+ });
96
+
97
+ const onSubmit = handleSubmit(async (values) => {
98
+ const payload: PredictRequest = {
99
+ patient_id: values.patient_id || null,
100
+ age: values.age,
101
+ sex: values.sex,
102
+ chest_pain_type: values.chest_pain_type,
103
+ max_hr: values.max_hr,
104
+ fasting_bs: Number(values.fasting_bs) as 0 | 1,
105
+ exercise_angina: values.exercise_angina,
106
+ st_slope: values.st_slope,
107
+ resting_bp: values.resting_bp ?? null,
108
+ cholesterol: values.cholesterol ?? null,
109
+ resting_ecg: values.resting_ecg ?? null,
110
+ oldpeak: values.oldpeak ?? null,
111
+ };
112
+ const response = await mutation.mutateAsync(payload);
113
+ setResult({ response, snapshot: values });
114
+ });
115
+
116
+ if (result) {
117
+ return (
118
+ <Ticket
119
+ response={result.response}
120
+ snapshot={result.snapshot}
121
+ onNew={() => {
122
+ setResult(null);
123
+ reset(DEFAULTS);
124
+ }}
125
+ />
126
+ );
127
+ }
128
+
129
+ return (
130
+ <form onSubmit={onSubmit} className="space-y-6">
131
+ <Card>
132
+ <CardContent className="grid gap-6 p-6">
133
+ <SectionTitle
134
+ icon={<ClipboardList className="h-4 w-4" />}
135
+ label={dict.form.sections.demographics}
136
+ />
137
+ <div className="grid gap-4 sm:grid-cols-3">
138
+ <Field
139
+ label={dict.form.fields.patientId}
140
+ error={errors.patient_id?.message}
141
+ >
142
+ <Input
143
+ placeholder={dict.form.fields.patientIdPlaceholder}
144
+ {...register("patient_id")}
145
+ />
146
+ </Field>
147
+ <Field label={dict.form.fields.age} error={errors.age?.message}>
148
+ <Input
149
+ type="number"
150
+ min={1}
151
+ max={120}
152
+ {...register("age")}
153
+ />
154
+ </Field>
155
+ <Field label={dict.form.fields.sex} error={errors.sex?.message}>
156
+ <Select
157
+ value={watch("sex")}
158
+ onValueChange={(v) => setValue("sex", v as "M" | "F")}
159
+ >
160
+ <SelectTrigger>
161
+ <SelectValue />
162
+ </SelectTrigger>
163
+ <SelectContent>
164
+ <SelectItem value="M">{dict.common.male}</SelectItem>
165
+ <SelectItem value="F">{dict.common.female}</SelectItem>
166
+ </SelectContent>
167
+ </Select>
168
+ </Field>
169
+ </div>
170
+ </CardContent>
171
+ </Card>
172
+
173
+ <Card>
174
+ <CardContent className="grid gap-6 p-6">
175
+ <SectionTitle
176
+ icon={<Activity className="h-4 w-4" />}
177
+ label={dict.form.sections.vitals}
178
+ />
179
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
180
+ <Field
181
+ label={dict.form.fields.restingBp}
182
+ error={errors.resting_bp?.message}
183
+ >
184
+ <Input type="number" min={60} max={250} {...register("resting_bp")} />
185
+ </Field>
186
+ <Field
187
+ label={dict.form.fields.cholesterol}
188
+ error={errors.cholesterol?.message}
189
+ >
190
+ <Input type="number" min={0} max={700} {...register("cholesterol")} />
191
+ </Field>
192
+ <Field label={dict.form.fields.maxHr} error={errors.max_hr?.message}>
193
+ <Input type="number" min={40} max={240} {...register("max_hr")} />
194
+ </Field>
195
+ <Field
196
+ label={dict.form.fields.fastingBs}
197
+ error={errors.fasting_bs?.message}
198
+ >
199
+ <Select
200
+ value={watch("fasting_bs")}
201
+ onValueChange={(v) =>
202
+ setValue("fasting_bs", v as "0" | "1")
203
+ }
204
+ >
205
+ <SelectTrigger>
206
+ <SelectValue />
207
+ </SelectTrigger>
208
+ <SelectContent>
209
+ <SelectItem value="0">{dict.common.no}</SelectItem>
210
+ <SelectItem value="1">{dict.common.yes}</SelectItem>
211
+ </SelectContent>
212
+ </Select>
213
+ </Field>
214
+ </div>
215
+ </CardContent>
216
+ </Card>
217
+
218
+ <Card>
219
+ <CardContent className="grid gap-6 p-6">
220
+ <SectionTitle
221
+ icon={<Stethoscope className="h-4 w-4" />}
222
+ label={dict.form.sections.symptoms}
223
+ />
224
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
225
+ <Field
226
+ label={dict.form.fields.chestPainType}
227
+ error={errors.chest_pain_type?.message}
228
+ >
229
+ <Select
230
+ value={watch("chest_pain_type")}
231
+ onValueChange={(v) =>
232
+ setValue("chest_pain_type", v as FormValues["chest_pain_type"])
233
+ }
234
+ >
235
+ <SelectTrigger>
236
+ <SelectValue />
237
+ </SelectTrigger>
238
+ <SelectContent>
239
+ {(["ASY", "ATA", "NAP", "TA"] as const).map((k) => (
240
+ <SelectItem key={k} value={k}>
241
+ {dict.form.options.chestPain[k]}
242
+ </SelectItem>
243
+ ))}
244
+ </SelectContent>
245
+ </Select>
246
+ </Field>
247
+
248
+ <Field
249
+ label={dict.form.fields.exerciseAngina}
250
+ error={errors.exercise_angina?.message}
251
+ >
252
+ <Select
253
+ value={watch("exercise_angina")}
254
+ onValueChange={(v) =>
255
+ setValue("exercise_angina", v as "Y" | "N")
256
+ }
257
+ >
258
+ <SelectTrigger>
259
+ <SelectValue />
260
+ </SelectTrigger>
261
+ <SelectContent>
262
+ <SelectItem value="N">{dict.common.no}</SelectItem>
263
+ <SelectItem value="Y">{dict.common.yes}</SelectItem>
264
+ </SelectContent>
265
+ </Select>
266
+ </Field>
267
+
268
+ <Field
269
+ label={dict.form.fields.restingEcg}
270
+ error={errors.resting_ecg?.message}
271
+ >
272
+ <Select
273
+ value={watch("resting_ecg") ?? "Normal"}
274
+ onValueChange={(v) =>
275
+ setValue("resting_ecg", v as FormValues["resting_ecg"])
276
+ }
277
+ >
278
+ <SelectTrigger>
279
+ <SelectValue />
280
+ </SelectTrigger>
281
+ <SelectContent>
282
+ {(["Normal", "ST", "LVH"] as const).map((k) => (
283
+ <SelectItem key={k} value={k}>
284
+ {dict.form.options.restingEcg[k]}
285
+ </SelectItem>
286
+ ))}
287
+ </SelectContent>
288
+ </Select>
289
+ </Field>
290
+
291
+ <Field
292
+ label={dict.form.fields.stSlope}
293
+ error={errors.st_slope?.message}
294
+ >
295
+ <Select
296
+ value={watch("st_slope")}
297
+ onValueChange={(v) =>
298
+ setValue("st_slope", v as FormValues["st_slope"])
299
+ }
300
+ >
301
+ <SelectTrigger>
302
+ <SelectValue />
303
+ </SelectTrigger>
304
+ <SelectContent>
305
+ {(["Up", "Flat", "Down"] as const).map((k) => (
306
+ <SelectItem key={k} value={k}>
307
+ {dict.form.options.stSlope[k]}
308
+ </SelectItem>
309
+ ))}
310
+ </SelectContent>
311
+ </Select>
312
+ </Field>
313
+
314
+ <Field
315
+ label={dict.form.fields.oldpeak}
316
+ error={errors.oldpeak?.message}
317
+ >
318
+ <Input
319
+ type="number"
320
+ step="0.1"
321
+ min={-5}
322
+ max={10}
323
+ {...register("oldpeak")}
324
+ />
325
+ </Field>
326
+ </div>
327
+ </CardContent>
328
+ </Card>
329
+
330
+ {mutation.error && (
331
+ <div className="rounded-md border border-destructive bg-destructive/10 px-4 py-3 text-sm text-destructive">
332
+ {dict.result.error}
333
+ </div>
334
+ )}
335
+
336
+ <div className="flex flex-wrap items-center justify-end gap-3">
337
+ <Button
338
+ type="button"
339
+ variant="ghost"
340
+ onClick={() => reset(DEFAULTS)}
341
+ disabled={mutation.isPending}
342
+ >
343
+ <RotateCcw className="h-4 w-4" />
344
+ {dict.form.actions.reset}
345
+ </Button>
346
+ <Button type="submit" size="lg" disabled={mutation.isPending}>
347
+ {mutation.isPending ? (
348
+ <Loader2 className="h-4 w-4 animate-spin" />
349
+ ) : (
350
+ <Heart className="h-4 w-4" />
351
+ )}
352
+ {mutation.isPending ? dict.result.predicting : dict.form.actions.submit}
353
+ </Button>
354
+ </div>
355
+ </form>
356
+ );
357
+ }
358
+
359
+ function SectionTitle({
360
+ icon,
361
+ label,
362
+ }: {
363
+ icon: React.ReactNode;
364
+ label: string;
365
+ }) {
366
+ return (
367
+ <div className="flex items-center gap-2 border-b border-border pb-3">
368
+ <div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary/10 text-primary">
369
+ {icon}
370
+ </div>
371
+ <span className="text-sm font-semibold tracking-wide">{label}</span>
372
+ </div>
373
+ );
374
+ }
375
+
376
+ function Field({
377
+ label,
378
+ error,
379
+ children,
380
+ }: {
381
+ label: string;
382
+ error?: string;
383
+ children: React.ReactNode;
384
+ }) {
385
+ return (
386
+ <div className="space-y-1.5">
387
+ <Label>{label}</Label>
388
+ {children}
389
+ {error && <p className="text-xs text-destructive">{error}</p>}
390
+ </div>
391
+ );
392
+ }
frontend/src/components/layout/header.tsx ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Search, Activity } from "lucide-react";
4
+ import { useT } from "@/i18n/context";
5
+ import { ThemeToggle } from "./theme-toggle";
6
+ import { LanguageToggle } from "./language-toggle";
7
+
8
+ export function Header() {
9
+ const { dict } = useT();
10
+ const now = new Date();
11
+ const date = now.toLocaleDateString(undefined, {
12
+ weekday: "long",
13
+ day: "2-digit",
14
+ month: "short",
15
+ year: "numeric",
16
+ });
17
+
18
+ return (
19
+ <header className="sticky top-0 z-30 flex h-16 items-center justify-between gap-4 border-b border-border bg-card/80 px-6 backdrop-blur lg:px-8">
20
+ <div className="flex items-center gap-3">
21
+ <div className="relative hidden md:block">
22
+ <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
23
+ <input
24
+ type="search"
25
+ placeholder={dict.header.search}
26
+ className="h-9 w-72 rounded-md border border-input bg-background pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
27
+ />
28
+ </div>
29
+ </div>
30
+
31
+ <div className="flex items-center gap-3">
32
+ <div className="hidden items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 text-xs text-muted-foreground md:flex">
33
+ <Activity className="h-3.5 w-3.5 text-success" />
34
+ <span className="font-medium">{dict.header.shift}</span>
35
+ <span className="text-muted-foreground/60">·</span>
36
+ <span>{date}</span>
37
+ </div>
38
+ <LanguageToggle />
39
+ <ThemeToggle />
40
+ <div className="flex items-center gap-2 border-l border-border pl-3">
41
+ <div className="hidden text-right text-xs leading-tight md:block">
42
+ <div className="font-semibold">{dict.header.user}</div>
43
+ <div className="text-muted-foreground">{dict.header.role}</div>
44
+ </div>
45
+ <div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
46
+ NP
47
+ </div>
48
+ </div>
49
+ </div>
50
+ </header>
51
+ );
52
+ }
frontend/src/components/layout/language-toggle.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Languages } from "lucide-react";
4
+ import { usePathname, useRouter } from "next/navigation";
5
+ import { locales, type Locale } from "@/i18n/config";
6
+ import { useT } from "@/i18n/context";
7
+ import {
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from "@/components/ui/select";
14
+
15
+ export function LanguageToggle() {
16
+ const { locale } = useT();
17
+ const router = useRouter();
18
+ const pathname = usePathname();
19
+
20
+ function handleChange(next: string) {
21
+ document.cookie = `locale=${next}; path=/; max-age=31536000`;
22
+ const segments = pathname.split("/");
23
+ segments[1] = next;
24
+ router.push(segments.join("/") || `/${next}`);
25
+ router.refresh();
26
+ }
27
+
28
+ return (
29
+ <Select value={locale} onValueChange={handleChange}>
30
+ <SelectTrigger className="h-9 w-[110px] gap-2">
31
+ <Languages className="h-4 w-4 opacity-70" />
32
+ <SelectValue />
33
+ </SelectTrigger>
34
+ <SelectContent>
35
+ {(locales as readonly Locale[]).map((l) => (
36
+ <SelectItem key={l} value={l}>
37
+ {l === "en" ? "English" : "Español"}
38
+ </SelectItem>
39
+ ))}
40
+ </SelectContent>
41
+ </Select>
42
+ );
43
+ }
frontend/src/components/layout/page-header.tsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function PageHeader({
2
+ title,
3
+ subtitle,
4
+ action,
5
+ }: {
6
+ title: string;
7
+ subtitle?: string;
8
+ action?: React.ReactNode;
9
+ }) {
10
+ return (
11
+ <div className="flex flex-wrap items-end justify-between gap-4 border-b border-border pb-4">
12
+ <div>
13
+ <h1 className="text-2xl font-bold tracking-tight">{title}</h1>
14
+ {subtitle && (
15
+ <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
16
+ )}
17
+ </div>
18
+ {action}
19
+ </div>
20
+ );
21
+ }
frontend/src/components/layout/sidebar.tsx ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import Image from "next/image";
5
+ import { usePathname } from "next/navigation";
6
+ import {
7
+ LayoutDashboard,
8
+ HeartPulse,
9
+ Users,
10
+ BarChart3,
11
+ Brain,
12
+ Settings,
13
+ } from "lucide-react";
14
+ import { useT } from "@/i18n/context";
15
+ import { cn } from "@/lib/utils";
16
+
17
+ export function Sidebar() {
18
+ const { dict, locale } = useT();
19
+ const pathname = usePathname();
20
+
21
+ const items = [
22
+ { href: "", label: dict.nav.dashboard, icon: LayoutDashboard },
23
+ { href: "new-evaluation", label: dict.nav.newEvaluation, icon: HeartPulse },
24
+ { href: "patients", label: dict.nav.patients, icon: Users },
25
+ { href: "eda", label: dict.nav.eda, icon: BarChart3 },
26
+ { href: "model", label: dict.nav.model, icon: Brain },
27
+ { href: "settings", label: dict.nav.settings, icon: Settings },
28
+ ];
29
+
30
+ return (
31
+ <aside className="sticky top-0 hidden h-screen w-64 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground lg:flex">
32
+ <div className="flex items-center gap-3 border-b border-sidebar-border px-5 py-5">
33
+ <div className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-lg bg-white/5 ring-1 ring-white/10">
34
+ <Image
35
+ src="/logu1.png"
36
+ alt="BeatSense"
37
+ width={40}
38
+ height={40}
39
+ className="h-9 w-9 object-contain"
40
+ priority
41
+ />
42
+ </div>
43
+ <div className="leading-tight">
44
+ <div className="text-sm font-bold tracking-wide text-white">
45
+ {dict.app.name}
46
+ </div>
47
+ <div className="text-[10px] uppercase tracking-widest text-sidebar-foreground/60">
48
+ {dict.app.tagline}
49
+ </div>
50
+ </div>
51
+ </div>
52
+
53
+ <nav className="flex-1 space-y-1 px-3 py-4">
54
+ {items.map(({ href, label, icon: Icon }) => {
55
+ const full = `/${locale}${href ? `/${href}` : ""}`;
56
+ const active =
57
+ href === ""
58
+ ? pathname === `/${locale}` || pathname === `/${locale}/`
59
+ : pathname.startsWith(full);
60
+ return (
61
+ <Link
62
+ key={href}
63
+ href={full}
64
+ className={cn(
65
+ "group flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
66
+ active
67
+ ? "bg-sidebar-accent text-white"
68
+ : "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-white"
69
+ )}
70
+ >
71
+ <Icon className="h-4 w-4" />
72
+ {label}
73
+ </Link>
74
+ );
75
+ })}
76
+ </nav>
77
+
78
+ <div className="border-t border-sidebar-border px-5 py-4 text-[11px] leading-relaxed text-sidebar-foreground/60">
79
+ <div className="font-mono">{dict.app.institution}</div>
80
+ <div className="mt-1">v1.0 · {new Date().getFullYear()}</div>
81
+ </div>
82
+ </aside>
83
+ );
84
+ }
frontend/src/components/layout/theme-toggle.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Moon, Sun } from "lucide-react";
4
+ import { useTheme } from "next-themes";
5
+ import { useEffect, useState } from "react";
6
+ import { Button } from "@/components/ui/button";
7
+
8
+ export function ThemeToggle() {
9
+ const { theme, setTheme, resolvedTheme } = useTheme();
10
+ const [mounted, setMounted] = useState(false);
11
+ useEffect(() => setMounted(true), []);
12
+
13
+ const current = mounted ? (resolvedTheme ?? theme) : "dark";
14
+ const next = current === "dark" ? "light" : "dark";
15
+
16
+ return (
17
+ <Button
18
+ variant="outline"
19
+ size="icon"
20
+ onClick={() => setTheme(next)}
21
+ aria-label="Toggle theme"
22
+ >
23
+ {current === "dark" ? (
24
+ <Sun className="h-4 w-4" />
25
+ ) : (
26
+ <Moon className="h-4 w-4" />
27
+ )}
28
+ </Button>
29
+ );
30
+ }
frontend/src/components/model/model-insights.tsx ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import {
5
+ Bar,
6
+ BarChart,
7
+ CartesianGrid,
8
+ Legend,
9
+ ResponsiveContainer,
10
+ Tooltip,
11
+ XAxis,
12
+ YAxis,
13
+ } from "recharts";
14
+ import { CheckCircle2 } from "lucide-react";
15
+ import { api } from "@/lib/api";
16
+ import { useT } from "@/i18n/context";
17
+ import { Card, CardContent } from "@/components/ui/card";
18
+ import { Badge } from "@/components/ui/badge";
19
+
20
+ export function ModelInsights() {
21
+ const { dict } = useT();
22
+ const { data, isLoading } = useQuery({
23
+ queryKey: ["model-info"],
24
+ queryFn: api.modelInfo,
25
+ });
26
+
27
+ if (isLoading || !data) {
28
+ return (
29
+ <Card>
30
+ <CardContent className="p-10 text-center text-sm text-muted-foreground">
31
+ {dict.common.loading}
32
+ </CardContent>
33
+ </Card>
34
+ );
35
+ }
36
+
37
+ const all = [
38
+ {
39
+ name: data.name,
40
+ accuracy: data.metrics.accuracy,
41
+ recall: data.metrics.recall,
42
+ roc_auc: data.metrics.roc_auc,
43
+ selected: true,
44
+ },
45
+ ...data.alternatives.map((a) => ({ ...a, selected: false })),
46
+ ];
47
+
48
+ const chartData = all.map((m) => ({
49
+ name: m.name,
50
+ Accuracy: +(m.accuracy * 100).toFixed(1),
51
+ Recall: +(m.recall * 100).toFixed(1),
52
+ "ROC-AUC": +(m.roc_auc * 100).toFixed(1),
53
+ }));
54
+
55
+ return (
56
+ <div className="grid gap-6 lg:grid-cols-3">
57
+ <Card className="lg:col-span-1">
58
+ <CardContent className="p-6">
59
+ <div className="flex items-center gap-2">
60
+ <CheckCircle2 className="h-5 w-5 text-success" />
61
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
62
+ {dict.model.selected}
63
+ </h2>
64
+ </div>
65
+ <div className="mt-3 text-2xl font-bold">{data.name}</div>
66
+ <Badge variant="success" className="mt-2">
67
+ threshold {data.threshold}%
68
+ </Badge>
69
+
70
+ <div className="mt-6 grid grid-cols-2 gap-3">
71
+ <Metric label={dict.model.metrics.accuracy} value={`${(data.metrics.accuracy * 100).toFixed(0)}%`} />
72
+ <Metric label={dict.model.metrics.recall} value={data.metrics.recall.toFixed(2)} />
73
+ <Metric label={dict.model.metrics.rocAuc} value={data.metrics.roc_auc.toFixed(3)} />
74
+ <Metric
75
+ label={dict.model.metrics.cv}
76
+ value={`${(data.metrics.cv_mean * 100).toFixed(1)}% ± ${(data.metrics.cv_std * 100).toFixed(1)}`}
77
+ />
78
+ </div>
79
+ </CardContent>
80
+ </Card>
81
+
82
+ <Card className="lg:col-span-2">
83
+ <CardContent className="p-6">
84
+ <h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
85
+ {dict.model.comparison}
86
+ </h2>
87
+ <ResponsiveContainer width="100%" height={300}>
88
+ <BarChart data={chartData}>
89
+ <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
90
+ <XAxis dataKey="name" tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
91
+ <YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" unit="%" />
92
+ <Tooltip
93
+ contentStyle={{
94
+ background: "var(--popover)",
95
+ border: "1px solid var(--border)",
96
+ borderRadius: 8,
97
+ fontSize: 12,
98
+ }}
99
+ />
100
+ <Legend wrapperStyle={{ fontSize: 12 }} />
101
+ <Bar dataKey="Accuracy" fill="var(--primary)" radius={[4, 4, 0, 0]} />
102
+ <Bar dataKey="Recall" fill="var(--success)" radius={[4, 4, 0, 0]} />
103
+ <Bar dataKey="ROC-AUC" fill="var(--accent-foreground)" radius={[4, 4, 0, 0]} />
104
+ </BarChart>
105
+ </ResponsiveContainer>
106
+ </CardContent>
107
+ </Card>
108
+
109
+ <Card className="lg:col-span-3">
110
+ <CardContent className="p-6">
111
+ <h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
112
+ {dict.model.rationale}
113
+ </h2>
114
+ <p className="text-sm leading-relaxed">{dict.model.rationaleText}</p>
115
+ </CardContent>
116
+ </Card>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ function Metric({ label, value }: { label: string; value: string }) {
122
+ return (
123
+ <div className="rounded-md border border-border bg-muted/30 p-3">
124
+ <div className="text-[10px] uppercase tracking-wide text-muted-foreground">
125
+ {label}
126
+ </div>
127
+ <div className="mt-1 text-lg font-bold tabular-nums">{value}</div>
128
+ </div>
129
+ );
130
+ }
frontend/src/components/patients/patients-table.tsx ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import { Download, Search } from "lucide-react";
6
+ import { api, type HistoryEntry } from "@/lib/api";
7
+ import { useT } from "@/i18n/context";
8
+ import { Card, CardContent } from "@/components/ui/card";
9
+ import { Badge } from "@/components/ui/badge";
10
+ import { Button } from "@/components/ui/button";
11
+
12
+ type Filter = "all" | "high" | "low";
13
+
14
+ export function PatientsTable() {
15
+ const { dict } = useT();
16
+ const [query, setQuery] = useState("");
17
+ const [filter, setFilter] = useState<Filter>("all");
18
+
19
+ const { data } = useQuery({ queryKey: ["history"], queryFn: api.history });
20
+
21
+ const rows = useMemo(() => {
22
+ let r = [...(data ?? [])].reverse();
23
+ if (filter === "high") r = r.filter((x) => x.risk === "ALTO");
24
+ if (filter === "low") r = r.filter((x) => x.risk !== "ALTO");
25
+ if (query.trim())
26
+ r = r.filter((x) =>
27
+ x.patient_id.toLowerCase().includes(query.toLowerCase())
28
+ );
29
+ return r;
30
+ }, [data, query, filter]);
31
+
32
+ function exportCsv() {
33
+ const headers = [
34
+ "Date","Time","Patient ID","Age","Sex","Max HR","Fasting BS","Exercise Angina","Chest Pain","ST Slope","Probability","Risk",
35
+ ];
36
+ const lines = [
37
+ headers.join(","),
38
+ ...rows.map((r: HistoryEntry) =>
39
+ [
40
+ r.date,
41
+ r.time,
42
+ r.patient_id,
43
+ r.age,
44
+ r.sex,
45
+ r.max_hr,
46
+ r.fasting_bs,
47
+ r.exercise_angina,
48
+ r.chest_pain_type,
49
+ r.st_slope,
50
+ r.probability,
51
+ r.risk,
52
+ ].join(",")
53
+ ),
54
+ ];
55
+ const blob = new Blob([lines.join("\n")], { type: "text/csv" });
56
+ const url = URL.createObjectURL(blob);
57
+ const a = document.createElement("a");
58
+ a.href = url;
59
+ a.download = `beatsense-patients-${new Date().toISOString().slice(0, 10)}.csv`;
60
+ a.click();
61
+ URL.revokeObjectURL(url);
62
+ }
63
+
64
+ return (
65
+ <Card>
66
+ <CardContent className="space-y-4 p-6">
67
+ <div className="flex flex-wrap items-center justify-between gap-3">
68
+ <div className="relative">
69
+ <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
70
+ <input
71
+ value={query}
72
+ onChange={(e) => setQuery(e.target.value)}
73
+ placeholder={dict.history.search}
74
+ className="h-9 w-72 rounded-md border border-input bg-background pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
75
+ />
76
+ </div>
77
+ <div className="flex items-center gap-2">
78
+ <FilterButton
79
+ label={dict.history.filterAll}
80
+ active={filter === "all"}
81
+ onClick={() => setFilter("all")}
82
+ />
83
+ <FilterButton
84
+ label={dict.history.filterHigh}
85
+ active={filter === "high"}
86
+ onClick={() => setFilter("high")}
87
+ />
88
+ <FilterButton
89
+ label={dict.history.filterLow}
90
+ active={filter === "low"}
91
+ onClick={() => setFilter("low")}
92
+ />
93
+ <Button variant="outline" size="sm" onClick={exportCsv}>
94
+ <Download className="h-4 w-4" />
95
+ {dict.common.export}
96
+ </Button>
97
+ </div>
98
+ </div>
99
+
100
+ <div className="overflow-x-auto rounded-md border border-border">
101
+ <table className="w-full text-sm">
102
+ <thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
103
+ <tr>
104
+ <Th>{dict.history.columns.date}</Th>
105
+ <Th>{dict.history.columns.time}</Th>
106
+ <Th>{dict.history.columns.patientId}</Th>
107
+ <Th className="text-right">{dict.history.columns.age}</Th>
108
+ <Th>{dict.history.columns.sex}</Th>
109
+ <Th className="text-right">{dict.history.columns.maxHr}</Th>
110
+ <Th>{dict.history.columns.fastingBs}</Th>
111
+ <Th>{dict.history.columns.exerciseAngina}</Th>
112
+ <Th>{dict.history.columns.chestPain}</Th>
113
+ <Th>{dict.history.columns.stSlope}</Th>
114
+ <Th className="text-right">{dict.history.columns.probability}</Th>
115
+ <Th className="text-right">{dict.history.columns.risk}</Th>
116
+ </tr>
117
+ </thead>
118
+ <tbody>
119
+ {rows.length === 0 ? (
120
+ <tr>
121
+ <td
122
+ colSpan={12}
123
+ className="py-10 text-center text-muted-foreground"
124
+ >
125
+ {dict.common.noData}
126
+ </td>
127
+ </tr>
128
+ ) : (
129
+ rows.map((r, i) => (
130
+ <tr key={`${r.patient_id}-${i}`} className="border-t border-border hover:bg-muted/20">
131
+ <Td>{r.date}</Td>
132
+ <Td>{r.time}</Td>
133
+ <Td className="font-mono text-xs">{r.patient_id}</Td>
134
+ <Td className="text-right">{r.age}</Td>
135
+ <Td>{r.sex}</Td>
136
+ <Td className="text-right">{r.max_hr}</Td>
137
+ <Td>{r.fasting_bs}</Td>
138
+ <Td>{r.exercise_angina}</Td>
139
+ <Td>{r.chest_pain_type}</Td>
140
+ <Td>{r.st_slope}</Td>
141
+ <Td className="text-right font-semibold">
142
+ {r.probability.toFixed(1)}%
143
+ </Td>
144
+ <Td className="text-right">
145
+ <Badge
146
+ variant={r.risk === "ALTO" ? "destructive" : "success"}
147
+ >
148
+ {r.risk === "ALTO" ? dict.common.high : dict.common.low}
149
+ </Badge>
150
+ </Td>
151
+ </tr>
152
+ ))
153
+ )}
154
+ </tbody>
155
+ </table>
156
+ </div>
157
+ </CardContent>
158
+ </Card>
159
+ );
160
+ }
161
+
162
+ function FilterButton({
163
+ label,
164
+ active,
165
+ onClick,
166
+ }: {
167
+ label: string;
168
+ active: boolean;
169
+ onClick: () => void;
170
+ }) {
171
+ return (
172
+ <button
173
+ onClick={onClick}
174
+ className={`h-9 rounded-md border px-3 text-xs font-medium transition-colors ${
175
+ active
176
+ ? "border-primary bg-primary/10 text-primary"
177
+ : "border-border bg-card text-muted-foreground hover:text-foreground"
178
+ }`}
179
+ >
180
+ {label}
181
+ </button>
182
+ );
183
+ }
184
+
185
+ function Th({
186
+ children,
187
+ className,
188
+ }: {
189
+ children: React.ReactNode;
190
+ className?: string;
191
+ }) {
192
+ return (
193
+ <th className={`px-3 py-2 text-left ${className ?? ""}`}>{children}</th>
194
+ );
195
+ }
196
+
197
+ function Td({
198
+ children,
199
+ className,
200
+ }: {
201
+ children: React.ReactNode;
202
+ className?: string;
203
+ }) {
204
+ return <td className={`px-3 py-2 ${className ?? ""}`}>{children}</td>;
205
+ }
frontend/src/components/providers.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { ThemeProvider } from "next-themes";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { useState, type ReactNode } from "react";
6
+ import { I18nProvider } from "@/i18n/context";
7
+ import type { Dictionary } from "@/i18n/dictionaries";
8
+ import type { Locale } from "@/i18n/config";
9
+
10
+ export function ThemeRoot({ children }: { children: ReactNode }) {
11
+ return (
12
+ <ThemeProvider
13
+ attribute="class"
14
+ defaultTheme="dark"
15
+ enableSystem
16
+ disableTransitionOnChange
17
+ >
18
+ {children}
19
+ </ThemeProvider>
20
+ );
21
+ }
22
+
23
+ export function Providers({
24
+ dict,
25
+ locale,
26
+ children,
27
+ }: {
28
+ dict: Dictionary;
29
+ locale: Locale;
30
+ children: ReactNode;
31
+ }) {
32
+ const [qc] = useState(
33
+ () =>
34
+ new QueryClient({
35
+ defaultOptions: {
36
+ queries: { staleTime: 30_000, refetchOnWindowFocus: false },
37
+ },
38
+ })
39
+ );
40
+ return (
41
+ <I18nProvider dict={dict} locale={locale}>
42
+ <QueryClientProvider client={qc}>{children}</QueryClientProvider>
43
+ </I18nProvider>
44
+ );
45
+ }
frontend/src/components/settings/settings-panel.tsx ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useTheme } from "next-themes";
4
+ import { useEffect, useState } from "react";
5
+ import { Card, CardContent } from "@/components/ui/card";
6
+ import { useT } from "@/i18n/context";
7
+ import { LanguageToggle } from "@/components/layout/language-toggle";
8
+ import { Sun, Moon, Monitor } from "lucide-react";
9
+
10
+ export function SettingsPanel() {
11
+ const { dict } = useT();
12
+ const { theme, setTheme } = useTheme();
13
+ const [mounted, setMounted] = useState(false);
14
+ useEffect(() => setMounted(true), []);
15
+
16
+ const options = [
17
+ { v: "light", label: dict.settings.themeLight, icon: Sun },
18
+ { v: "dark", label: dict.settings.themeDark, icon: Moon },
19
+ { v: "system", label: dict.settings.themeSystem, icon: Monitor },
20
+ ];
21
+
22
+ return (
23
+ <div className="grid gap-6 lg:grid-cols-2">
24
+ <Card>
25
+ <CardContent className="space-y-3 p-6">
26
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
27
+ {dict.settings.language}
28
+ </h2>
29
+ <LanguageToggle />
30
+ </CardContent>
31
+ </Card>
32
+
33
+ <Card>
34
+ <CardContent className="space-y-3 p-6">
35
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
36
+ {dict.settings.theme}
37
+ </h2>
38
+ <div className="grid grid-cols-3 gap-2">
39
+ {options.map(({ v, label, icon: Icon }) => {
40
+ const active = mounted && theme === v;
41
+ return (
42
+ <button
43
+ key={v}
44
+ onClick={() => setTheme(v)}
45
+ className={`flex flex-col items-center gap-2 rounded-md border p-3 text-sm transition-colors ${
46
+ active
47
+ ? "border-primary bg-primary/10 text-primary"
48
+ : "border-border bg-card text-muted-foreground hover:text-foreground"
49
+ }`}
50
+ >
51
+ <Icon className="h-5 w-5" />
52
+ {label}
53
+ </button>
54
+ );
55
+ })}
56
+ </div>
57
+ </CardContent>
58
+ </Card>
59
+ </div>
60
+ );
61
+ }
frontend/src/components/ticket/ticket.tsx ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useRef } from "react";
4
+ import Image from "next/image";
5
+ import { Printer, Plus } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import { useT } from "@/i18n/context";
8
+ import type { PredictResponse } from "@/lib/api";
9
+
10
+ export type FormSnapshot = {
11
+ patient_id?: string;
12
+ age: number;
13
+ sex: "M" | "F";
14
+ max_hr: number;
15
+ fasting_bs: "0" | "1";
16
+ exercise_angina: "Y" | "N";
17
+ chest_pain_type: "ASY" | "ATA" | "NAP" | "TA";
18
+ st_slope: "Up" | "Flat" | "Down";
19
+ resting_bp?: number;
20
+ cholesterol?: number;
21
+ resting_ecg?: "Normal" | "ST" | "LVH";
22
+ oldpeak?: number;
23
+ };
24
+
25
+ export function Ticket({
26
+ response,
27
+ snapshot,
28
+ onNew,
29
+ }: {
30
+ response: PredictResponse;
31
+ snapshot: FormSnapshot;
32
+ onNew: () => void;
33
+ }) {
34
+ const { dict } = useT();
35
+ const ticketRef = useRef<HTMLDivElement>(null);
36
+
37
+ const ts = new Date(response.timestamp);
38
+ const date = ts.toLocaleDateString();
39
+ const time = ts.toLocaleTimeString();
40
+ const isHigh = response.classification === "HIGH";
41
+
42
+ return (
43
+ <div className="mx-auto max-w-2xl space-y-4">
44
+ <div className="flex justify-end gap-2 print:hidden">
45
+ <Button variant="outline" onClick={onNew}>
46
+ <Plus className="h-4 w-4" />
47
+ {dict.ticket.newAssessment}
48
+ </Button>
49
+ <Button onClick={() => window.print()}>
50
+ <Printer className="h-4 w-4" />
51
+ {dict.common.print}
52
+ </Button>
53
+ </div>
54
+
55
+ <div
56
+ ref={ticketRef}
57
+ className="print-area mx-auto rounded-lg border border-dashed border-foreground/30 bg-card p-8 font-mono text-[13px] leading-relaxed text-foreground shadow-2xl print:shadow-none"
58
+ style={{ maxWidth: "420px" }}
59
+ >
60
+ <div className="flex flex-col items-center gap-2 pb-3 text-center">
61
+ <div className="flex h-14 w-14 items-center justify-center overflow-hidden rounded-md">
62
+ <Image
63
+ src="/logu1.png"
64
+ alt="BeatSense"
65
+ width={56}
66
+ height={56}
67
+ className="h-12 w-12 object-contain"
68
+ />
69
+ </div>
70
+ <div className="text-base font-bold tracking-widest uppercase">
71
+ {dict.app.name}
72
+ </div>
73
+ <div className="text-[10px] uppercase tracking-wider opacity-70">
74
+ {dict.ticket.facility}
75
+ </div>
76
+ </div>
77
+
78
+ <Divider />
79
+
80
+ <div className="grid grid-cols-2 gap-y-1 text-[12px]">
81
+ <span className="opacity-70">{dict.ticket.code}</span>
82
+ <span className="text-right font-bold">{response.patient_id}</span>
83
+ <span className="opacity-70">{dict.ticket.date}</span>
84
+ <span className="text-right">{date}</span>
85
+ <span className="opacity-70">{dict.ticket.time}</span>
86
+ <span className="text-right">{time}</span>
87
+ <span className="opacity-70">{dict.ticket.clinician}</span>
88
+ <span className="text-right">{dict.header.user}</span>
89
+ </div>
90
+
91
+ <Divider />
92
+
93
+ <div className="text-[10px] uppercase tracking-widest opacity-70">
94
+ {dict.ticket.vitals}
95
+ </div>
96
+ <div className="mt-2 space-y-0.5 text-[12px]">
97
+ <Row label={dict.form.fields.age} value={`${snapshot.age}`} />
98
+ <Row
99
+ label={dict.form.fields.sex}
100
+ value={snapshot.sex === "M" ? dict.common.male : dict.common.female}
101
+ />
102
+ {snapshot.resting_bp ? (
103
+ <Row
104
+ label={dict.form.fields.restingBp}
105
+ value={`${snapshot.resting_bp} mmHg`}
106
+ />
107
+ ) : null}
108
+ {snapshot.cholesterol ? (
109
+ <Row
110
+ label={dict.form.fields.cholesterol}
111
+ value={`${snapshot.cholesterol} mg/dL`}
112
+ />
113
+ ) : null}
114
+ <Row
115
+ label={dict.form.fields.maxHr}
116
+ value={`${snapshot.max_hr} bpm`}
117
+ />
118
+ <Row
119
+ label={dict.form.fields.fastingBs}
120
+ value={snapshot.fasting_bs === "1" ? dict.common.yes : dict.common.no}
121
+ />
122
+ <Row
123
+ label={dict.form.fields.chestPainType}
124
+ value={snapshot.chest_pain_type}
125
+ />
126
+ <Row
127
+ label={dict.form.fields.exerciseAngina}
128
+ value={
129
+ snapshot.exercise_angina === "Y" ? dict.common.yes : dict.common.no
130
+ }
131
+ />
132
+ <Row label={dict.form.fields.stSlope} value={snapshot.st_slope} />
133
+ </div>
134
+
135
+ <Divider />
136
+
137
+ <div className="text-center">
138
+ <div className="text-[10px] uppercase tracking-widest opacity-70">
139
+ {dict.ticket.result}
140
+ </div>
141
+ <div className="mt-2 text-4xl font-black">
142
+ {response.probability.toFixed(1)}%
143
+ </div>
144
+ <div className="mt-1 text-[10px] uppercase opacity-60">
145
+ {dict.ticket.riskScore} · {dict.ticket.threshold}: {response.threshold}%
146
+ </div>
147
+ <div
148
+ className={`mt-3 inline-block rounded-sm border-2 px-3 py-1 text-sm font-bold tracking-widest ${
149
+ isHigh
150
+ ? "border-destructive text-destructive"
151
+ : "border-success text-success"
152
+ }`}
153
+ >
154
+ {isHigh ? dict.ticket.highRisk : dict.ticket.lowRisk}
155
+ </div>
156
+ <ProbabilityBar value={response.probability} threshold={response.threshold} />
157
+ </div>
158
+
159
+ <Divider />
160
+
161
+ <div className="text-[10px] uppercase tracking-widest opacity-70">
162
+ {dict.ticket.recommendation}
163
+ </div>
164
+ <p className="mt-1 text-[12px]">
165
+ {isHigh ? dict.ticket.recHigh : dict.ticket.recLow}
166
+ </p>
167
+
168
+ <Divider />
169
+
170
+ <div className="mt-2 flex flex-col items-center gap-2 text-[10px] opacity-70">
171
+ <Barcode value={response.patient_id} />
172
+ <div className="text-center">{dict.ticket.footer}</div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ );
177
+ }
178
+
179
+ function Row({ label, value }: { label: string; value: string }) {
180
+ return (
181
+ <div className="flex justify-between gap-2">
182
+ <span className="opacity-70">{label}</span>
183
+ <span className="text-right font-medium">{value}</span>
184
+ </div>
185
+ );
186
+ }
187
+
188
+ function Divider() {
189
+ return (
190
+ <div
191
+ className="my-3 h-px w-full"
192
+ style={{
193
+ background:
194
+ "repeating-linear-gradient(90deg, currentColor 0 4px, transparent 4px 8px)",
195
+ opacity: 0.4,
196
+ }}
197
+ />
198
+ );
199
+ }
200
+
201
+ function ProbabilityBar({
202
+ value,
203
+ threshold,
204
+ }: {
205
+ value: number;
206
+ threshold: number;
207
+ }) {
208
+ return (
209
+ <div className="mt-3">
210
+ <div className="relative h-2 w-full overflow-hidden rounded-sm border border-foreground/30">
211
+ <div
212
+ className="h-full"
213
+ style={{
214
+ width: `${Math.min(100, value)}%`,
215
+ background: value >= threshold ? "var(--destructive)" : "var(--success)",
216
+ }}
217
+ />
218
+ <div
219
+ className="absolute top-0 h-full w-px bg-foreground/80"
220
+ style={{ left: `${threshold}%` }}
221
+ />
222
+ </div>
223
+ <div className="mt-1 flex justify-between font-mono text-[9px] opacity-60">
224
+ <span>0%</span>
225
+ <span>{threshold}%</span>
226
+ <span>100%</span>
227
+ </div>
228
+ </div>
229
+ );
230
+ }
231
+
232
+ function Barcode({ value }: { value: string }) {
233
+ const bars = Array.from(value).flatMap((c, i) => {
234
+ const code = c.charCodeAt(0) + i;
235
+ return [
236
+ { w: (code % 4) + 1, gap: 1 },
237
+ { w: ((code >> 2) % 3) + 1, gap: 2 },
238
+ ];
239
+ });
240
+ return (
241
+ <div className="flex flex-col items-center">
242
+ <div className="flex h-10 items-end gap-[1px]">
243
+ {bars.map((b, i) => (
244
+ <div
245
+ key={i}
246
+ className="bg-foreground"
247
+ style={{ width: `${b.w}px`, height: "100%" }}
248
+ />
249
+ ))}
250
+ </div>
251
+ <div className="mt-1 font-mono text-[10px] tracking-widest">{value}</div>
252
+ </div>
253
+ );
254
+ }
frontend/src/components/ui/badge.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ const badgeVariants = cva(
6
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default: "border-transparent bg-primary text-primary-foreground",
11
+ secondary: "border-transparent bg-secondary text-secondary-foreground",
12
+ destructive:
13
+ "border-transparent bg-destructive text-destructive-foreground",
14
+ success: "border-transparent bg-success text-success-foreground",
15
+ warning: "border-transparent bg-warning text-warning-foreground",
16
+ outline: "text-foreground border-border",
17
+ },
18
+ },
19
+ defaultVariants: { variant: "default" },
20
+ }
21
+ );
22
+
23
+ export interface BadgeProps
24
+ extends React.HTMLAttributes<HTMLDivElement>,
25
+ VariantProps<typeof badgeVariants> {}
26
+
27
+ export function Badge({ className, variant, ...props }: BadgeProps) {
28
+ return (
29
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
30
+ );
31
+ }
frontend/src/components/ui/button.tsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ const buttonVariants = cva(
7
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default:
12
+ "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm",
13
+ destructive:
14
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15
+ outline:
16
+ "border border-border bg-card hover:bg-muted text-foreground",
17
+ secondary:
18
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19
+ ghost: "hover:bg-muted text-foreground",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default: "h-10 px-4 py-2",
24
+ sm: "h-8 rounded-md px-3 text-xs",
25
+ lg: "h-11 rounded-md px-8 text-base",
26
+ icon: "h-9 w-9",
27
+ },
28
+ },
29
+ defaultVariants: { variant: "default", size: "default" },
30
+ }
31
+ );
32
+
33
+ export interface ButtonProps
34
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
35
+ VariantProps<typeof buttonVariants> {
36
+ asChild?: boolean;
37
+ }
38
+
39
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
40
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
41
+ const Comp = asChild ? Slot : "button";
42
+ return (
43
+ <Comp
44
+ className={cn(buttonVariants({ variant, size, className }))}
45
+ ref={ref}
46
+ {...props}
47
+ />
48
+ );
49
+ }
50
+ );
51
+ Button.displayName = "Button";
52
+
53
+ export { Button, buttonVariants };
frontend/src/components/ui/card.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ export const Card = React.forwardRef<
5
+ HTMLDivElement,
6
+ React.HTMLAttributes<HTMLDivElement>
7
+ >(({ className, ...props }, ref) => (
8
+ <div
9
+ ref={ref}
10
+ className={cn(
11
+ "rounded-xl border border-border bg-card text-card-foreground shadow-sm",
12
+ className
13
+ )}
14
+ {...props}
15
+ />
16
+ ));
17
+ Card.displayName = "Card";
18
+
19
+ export const CardHeader = React.forwardRef<
20
+ HTMLDivElement,
21
+ React.HTMLAttributes<HTMLDivElement>
22
+ >(({ className, ...props }, ref) => (
23
+ <div
24
+ ref={ref}
25
+ className={cn("flex flex-col gap-1.5 p-6 pb-4", className)}
26
+ {...props}
27
+ />
28
+ ));
29
+ CardHeader.displayName = "CardHeader";
30
+
31
+ export const CardTitle = React.forwardRef<
32
+ HTMLHeadingElement,
33
+ React.HTMLAttributes<HTMLHeadingElement>
34
+ >(({ className, ...props }, ref) => (
35
+ <h3
36
+ ref={ref}
37
+ className={cn("text-base font-semibold tracking-tight", className)}
38
+ {...props}
39
+ />
40
+ ));
41
+ CardTitle.displayName = "CardTitle";
42
+
43
+ export const CardDescription = React.forwardRef<
44
+ HTMLParagraphElement,
45
+ React.HTMLAttributes<HTMLParagraphElement>
46
+ >(({ className, ...props }, ref) => (
47
+ <p
48
+ ref={ref}
49
+ className={cn("text-sm text-muted-foreground", className)}
50
+ {...props}
51
+ />
52
+ ));
53
+ CardDescription.displayName = "CardDescription";
54
+
55
+ export const CardContent = React.forwardRef<
56
+ HTMLDivElement,
57
+ React.HTMLAttributes<HTMLDivElement>
58
+ >(({ className, ...props }, ref) => (
59
+ <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
60
+ ));
61
+ CardContent.displayName = "CardContent";
62
+
63
+ export const CardFooter = React.forwardRef<
64
+ HTMLDivElement,
65
+ React.HTMLAttributes<HTMLDivElement>
66
+ >(({ className, ...props }, ref) => (
67
+ <div
68
+ ref={ref}
69
+ className={cn("flex items-center p-6 pt-0", className)}
70
+ {...props}
71
+ />
72
+ ));
73
+ CardFooter.displayName = "CardFooter";
frontend/src/components/ui/input.tsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ export const Input = React.forwardRef<
5
+ HTMLInputElement,
6
+ React.InputHTMLAttributes<HTMLInputElement>
7
+ >(({ className, type, ...props }, ref) => (
8
+ <input
9
+ type={type}
10
+ ref={ref}
11
+ className={cn(
12
+ "flex h-10 w-full rounded-md border border-input bg-card px-3 py-2 text-sm text-foreground shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
13
+ className
14
+ )}
15
+ {...props}
16
+ />
17
+ ));
18
+ Input.displayName = "Input";
frontend/src/components/ui/label.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as LabelPrimitive from "@radix-ui/react-label";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ export const Label = React.forwardRef<
8
+ React.ElementRef<typeof LabelPrimitive.Root>,
9
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
10
+ >(({ className, ...props }, ref) => (
11
+ <LabelPrimitive.Root
12
+ ref={ref}
13
+ className={cn(
14
+ "text-xs font-medium uppercase tracking-wide text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
15
+ className
16
+ )}
17
+ {...props}
18
+ />
19
+ ));
20
+ Label.displayName = "Label";
frontend/src/components/ui/select.tsx ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as SelectPrimitive from "@radix-ui/react-select";
5
+ import { Check, ChevronDown } from "lucide-react";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ export const Select = SelectPrimitive.Root;
9
+ export const SelectGroup = SelectPrimitive.Group;
10
+ export const SelectValue = SelectPrimitive.Value;
11
+
12
+ export const SelectTrigger = React.forwardRef<
13
+ React.ElementRef<typeof SelectPrimitive.Trigger>,
14
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
15
+ >(({ className, children, ...props }, ref) => (
16
+ <SelectPrimitive.Trigger
17
+ ref={ref}
18
+ className={cn(
19
+ "flex h-10 w-full items-center justify-between rounded-md border border-input bg-card px-3 py-2 text-sm text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
20
+ className
21
+ )}
22
+ {...props}
23
+ >
24
+ {children}
25
+ <SelectPrimitive.Icon asChild>
26
+ <ChevronDown className="h-4 w-4 opacity-60" />
27
+ </SelectPrimitive.Icon>
28
+ </SelectPrimitive.Trigger>
29
+ ));
30
+ SelectTrigger.displayName = "SelectTrigger";
31
+
32
+ export const SelectContent = React.forwardRef<
33
+ React.ElementRef<typeof SelectPrimitive.Content>,
34
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
35
+ >(({ className, children, position = "popper", ...props }, ref) => (
36
+ <SelectPrimitive.Portal>
37
+ <SelectPrimitive.Content
38
+ ref={ref}
39
+ className={cn(
40
+ "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md",
41
+ position === "popper" && "translate-y-1",
42
+ className
43
+ )}
44
+ position={position}
45
+ {...props}
46
+ >
47
+ <SelectPrimitive.Viewport
48
+ className={cn(
49
+ "p-1",
50
+ position === "popper" &&
51
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
52
+ )}
53
+ >
54
+ {children}
55
+ </SelectPrimitive.Viewport>
56
+ </SelectPrimitive.Content>
57
+ </SelectPrimitive.Portal>
58
+ ));
59
+ SelectContent.displayName = "SelectContent";
60
+
61
+ export const SelectItem = React.forwardRef<
62
+ React.ElementRef<typeof SelectPrimitive.Item>,
63
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
64
+ >(({ className, children, ...props }, ref) => (
65
+ <SelectPrimitive.Item
66
+ ref={ref}
67
+ className={cn(
68
+ "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
69
+ className
70
+ )}
71
+ {...props}
72
+ >
73
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
74
+ <SelectPrimitive.ItemIndicator>
75
+ <Check className="h-4 w-4" />
76
+ </SelectPrimitive.ItemIndicator>
77
+ </span>
78
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
79
+ </SelectPrimitive.Item>
80
+ ));
81
+ SelectItem.displayName = "SelectItem";