diff --git a/src/App.css b/src/App.css index f90339d..a541039 100644 --- a/src/App.css +++ b/src/App.css @@ -1,184 +1,591 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; +:root { + --surface: #f4f4f6; + --surface-hover: #ededf0; + + --danger-bg: #fff5f5; + --danger-border: #fecaca; + --danger-header: #fee2e2; + --danger-text: #991b1b; + + --warning-bg: #fffbeb; + --warning-border: #fde68a; + --warning-header: #fef3c7; + --warning-text: #92400e; + + --info-bg: #f0f9ff; + --info-border: #bae6fd; + --info-header: #e0f2fe; + --info-text: #075985; + + --badge-low-bg: #dcfce7; + --badge-low-text: #166534; + --badge-medium-bg: #fef9c3; + --badge-medium-text: #854d0e; + --badge-high-bg: #fee2e2; + --badge-high-text: #991b1b; +} + +@media (prefers-color-scheme: dark) { + :root { + --surface: #1e1f27; + --surface-hover: #25262f; + + --danger-bg: #1f1315; + --danger-border: #5b2128; + --danger-header: #2d1517; + --danger-text: #fca5a5; + + --warning-bg: #1c1509; + --warning-border: #5c3d10; + --warning-header: #271c0a; + --warning-text: #fcd34d; + + --info-bg: #091520; + --info-border: #0c3a54; + --info-header: #0d1f2e; + --info-text: #7dd3fc; + + --badge-low-bg: rgba(34, 197, 94, 0.15); + --badge-low-text: #86efac; + --badge-medium-bg: rgba(234, 179, 8, 0.15); + --badge-medium-text: #fde047; + --badge-high-bg: rgba(239, 68, 68, 0.15); + --badge-high-text: #fca5a5; + } +} + +/* ── Layout ───────────────────────────────────────────────────────────────── */ + +.app { + max-width: 1300px; + margin: 0 auto; + padding: 24px 20px 48px; +} + +.app-header { + display: flex; + align-items: center; + gap: 16px; margin-bottom: 24px; - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; + h1 { + margin: 0; + font-size: 1.6rem; + letter-spacing: -0.5px; + color: var(--text-h); } } -.hero { - position: relative; +.fetching-badge { + font-size: 0.75rem; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + padding: 2px 10px; + border-radius: 12px; +} - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } +/* ── Alert cards ──────────────────────────────────────────────────────────── */ - .base { - width: 170px; - position: relative; - z-index: 0; - } +.alerts-panel { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + margin-bottom: 28px; - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); + @media (max-width: 768px) { + grid-template-columns: 1fr; } } -#center { +.alert-card { + border-radius: 10px; + border: 1px solid; + overflow: hidden; +} + +.alert-card--danger { + background: var(--danger-bg); + border-color: var(--danger-border); +} +.alert-card--warning { + background: var(--warning-bg); + border-color: var(--warning-border); +} +.alert-card--info { + background: var(--info-bg); + border-color: var(--info-border); +} + +.alert-card__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + font-weight: 600; + font-size: 0.82rem; + letter-spacing: 0.02em; + text-transform: uppercase; + + .alert-card--danger & { + background: var(--danger-header); + color: var(--danger-text); + border-bottom: 1px solid var(--danger-border); + } + .alert-card--warning & { + background: var(--warning-header); + color: var(--warning-text); + border-bottom: 1px solid var(--warning-border); + } + .alert-card--info & { + background: var(--info-header); + color: var(--info-text); + border-bottom: 1px solid var(--info-border); + } +} + +.alert-card__count { + background: rgba(0 0 0 / 0.1); + border-radius: 10px; + padding: 1px 8px; + font-size: 0.8rem; + font-weight: 700; + text-transform: none; + letter-spacing: 0; +} + +@media (prefers-color-scheme: dark) { + .alert-card__count { + background: rgba(255 255 255 / 0.1); + } +} + +.alert-card__list { + list-style: none; + margin: 0; + padding: 10px 14px 12px; + display: flex; + flex-wrap: wrap; + gap: 6px; + + li { + font-size: 0.8rem; + font-family: var(--mono); + padding: 2px 8px; + border-radius: 4px; + + .alert-card--danger & { + background: var(--danger-header); + color: var(--danger-text); + } + .alert-card--warning & { + background: var(--warning-header); + color: var(--warning-text); + } + .alert-card--info & { + background: var(--info-header); + color: var(--info-text); + } + } +} + +.alert-card__empty { + margin: 0; + padding: 10px 14px 12px; + font-size: 0.85rem; + color: var(--text); + + &--error { + color: var(--danger-text); + } +} + +/* ── State messages ───────────────────────────────────────────────────────── */ + +.state-message { + text-align: center; + padding: 48px; + color: var(--text); + + &.error { + color: var(--danger-text); + + button { + margin-top: 12px; + padding: 6px 16px; + border-radius: 6px; + border: 1px solid var(--danger-border); + background: var(--danger-bg); + color: var(--danger-text); + cursor: pointer; + + &:hover { + background: var(--danger-header); + } + } + } +} + +/* ── Table ────────────────────────────────────────────────────────────────── */ + +.table-wrapper { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 10px; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + + th { + background: var(--surface); + color: var(--text); + text-align: left; + padding: 10px 14px; + font-weight: 600; + font-size: 0.78rem; + letter-spacing: 0.05em; + text-transform: uppercase; + border-bottom: 1px solid var(--border); + white-space: nowrap; + } + + td { + padding: 10px 14px; + color: var(--text-h); + border-bottom: 1px solid var(--border); + vertical-align: middle; + } + + tbody tr:last-child td { + border-bottom: none; + } + + tbody tr:hover td { + background: var(--surface); + } +} + +.missing { + color: var(--border); +} + +/* ── Risk badge ───────────────────────────────────────────────────────────── */ + +.risk-badge { + display: inline-block; + min-width: 24px; + text-align: center; + font-weight: 700; + font-size: 0.78rem; + padding: 2px 8px; + border-radius: 12px; +} + +.risk-badge--low { + background: var(--badge-low-bg); + color: var(--badge-low-text); +} +.risk-badge--medium { + background: var(--badge-medium-bg); + color: var(--badge-medium-text); +} +.risk-badge--high { + background: var(--badge-high-bg); + color: var(--badge-high-text); +} + +.data-issue-flag { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 6px; + font-size: 0.7rem; + font-weight: 800; + color: var(--warning-text); + background: var(--warning-header); + border: 1px solid var(--warning-border); + border-radius: 50%; + width: 16px; + height: 16px; + cursor: default; + vertical-align: middle; +} + +/* ── Pagination ───────────────────────────────────────────────────────────── */ + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-top: 20px; + + span { + font-size: 0.875rem; + color: var(--text); + } + + button { + padding: 6px 18px; + border-radius: 6px; + border: 1px solid var(--accent-border); + background: var(--accent-bg); + color: var(--accent); + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: background 0.15s, border-color 0.15s; + + &:hover:not(:disabled) { + background: var(--accent-bg); + border-color: var(--accent); + filter: brightness(1.1); + } + + &:disabled { + opacity: 0.35; + cursor: not-allowed; + } + } +} + +/* ── Submission panel ─────────────────────────────────────────────────────── */ + +.submission-panel { + margin-top: 40px; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} + +.submission-panel__header { + padding: 18px 20px 14px; + border-bottom: 1px solid var(--border); + background: var(--surface); + + h2 { + margin: 0 0 4px; + font-size: 1.1rem; + color: var(--text-h); + } +} + +.submission-panel__note { + font-size: 0.85rem; + color: var(--text); + margin: 0; +} + +/* ── Payload preview ─────────────────────────────────────────────────── */ + +.payload-preview { display: flex; flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; +} - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; +.payload-row { + display: grid; + grid-template-columns: 280px 1fr; + align-items: start; + gap: 12px; + padding: 12px 20px; + border-bottom: 1px solid var(--border); + + &:last-child { + border-bottom: none; + } + + @media (max-width: 640px) { + grid-template-columns: 1fr; } } -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; +.payload-row--danger { background: var(--danger-bg); } +.payload-row--warning { background: var(--warning-bg); } +.payload-row--info { background: var(--info-bg); } + +.payload-row__label { display: flex; + align-items: center; gap: 8px; - margin: 32px 0 0; + padding-top: 2px; - .logo { - height: 18px; - } + code { + font-size: 0.8rem; + background: transparent; + padding: 0; - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } + .payload-row--danger & { color: var(--danger-text); } + .payload-row--warning & { color: var(--warning-text); } + .payload-row--info & { color: var(--info-text); } } } -#spacer { - height: 88px; +.payload-row__count { + font-size: 0.75rem; + font-weight: 700; + border-radius: 10px; + padding: 1px 7px; + + .payload-row--danger & { background: var(--danger-header); color: var(--danger-text); } + .payload-row--warning & { background: var(--warning-header); color: var(--warning-text); } + .payload-row--info & { background: var(--info-header); color: var(--info-text); } +} + +.payload-row__ids { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 24px; +} + +.payload-row__empty { + font-size: 0.8rem; + font-family: var(--mono); + color: var(--text); + opacity: 0.5; +} + +.payload-id { + font-size: 0.8rem; + font-family: var(--mono); + padding: 2px 8px; + border-radius: 4px; + + .payload-row--danger & { background: var(--danger-header); color: var(--danger-text); } + .payload-row--warning & { background: var(--warning-header); color: var(--warning-text); } + .payload-row--info & { background: var(--info-header); color: var(--info-text); } +} + +/* ── Submit button & error ────────────────────────────────────────────── */ + +.submission-panel__actions { + padding: 16px 20px; border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; + background: var(--surface); + display: flex; + align-items: center; + gap: 16px; +} + +.submit-btn { + padding: 10px 28px; + border-radius: 7px; + border: none; + background: var(--accent); + color: #fff; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s, filter 0.15s; + + &:hover:not(:disabled) { + filter: brightness(1.1); + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; } } -.ticks { - position: relative; +.submit-error { + margin: 0; + font-size: 0.875rem; + color: var(--danger-text); +} + +/* ── Submission result ────────────────────────────────────────────────── */ + +.submission-result { + margin: 0; + border-top: 1px solid var(--border); +} + +.submission-result__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 20px; + flex-wrap: wrap; +} + +.submission-result--pass .submission-result__header { background: var(--badge-low-bg); } +.submission-result--fail .submission-result__header { background: var(--danger-bg); } + +.submission-result__score { + display: flex; + align-items: center; + gap: 12px; +} + +.submission-result__pct { + font-size: 2rem; + font-weight: 800; + color: var(--text-h); + line-height: 1; +} + +.submission-result__status { + font-size: 0.85rem; + font-weight: 700; + padding: 3px 12px; + border-radius: 12px; +} + +.status--pass { + background: var(--badge-low-bg); + color: var(--badge-low-text); +} +.status--fail { + background: var(--badge-high-bg); + color: var(--badge-high-text); +} + +.submission-result__meta { + display: flex; + gap: 16px; + font-size: 0.82rem; + color: var(--text); + flex-wrap: wrap; +} + +.personal-best { + color: var(--accent); + font-weight: 600; +} + +.breakdown-table { width: 100%; + border-radius: 0; + border-top: 1px solid var(--border); - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } + th, td { + text-align: right; + padding: 9px 20px; - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); + &:first-child { + text-align: left; + } } } + +.feedback-list { + margin: 0; + padding: 12px 20px; + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; + border-top: 1px solid var(--border); + font-size: 0.875rem; + + li { color: var(--text-h); } + + &--strengths { background: var(--badge-low-bg); } + &--issues { background: var(--warning-bg); } +} diff --git a/src/App.tsx b/src/App.tsx index 46a5992..1cd1c57 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,121 +1,62 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' -import './App.css' +import { useState } from 'react'; +import { AlertsPanel, PatientTable, SubmissionPanel } from './components' +import { useAllPatients, usePatientRiskAnalysis } from './hooks'; +import './App.css'; -function App() { - const [count, setCount] = useState(0) +const PAGE_SIZE = 10; + +const App = () => { + const [page, setPage] = useState(1); + const { + data: allPatients, + isLoading, + isFetching, + isError, + error, + refetch, + } = useAllPatients(); + const riskAnalysis = usePatientRiskAnalysis(); + const totalPatients = allPatients?.length ?? 0; + const totalPages = Math.max(1, Math.ceil(totalPatients / PAGE_SIZE)); + const pagePatients = allPatients?.slice( + (page - 1) * PAGE_SIZE, page * PAGE_SIZE + ) ?? []; return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) +
+
+

Patient Records

+ {isFetching && !isLoading && Updating...} +
+ +
+ {isLoading &&
Loading patients...
} + {isError && ( +
+

{error.message}

+ +
+ )} + {allPatients && ( + + )} +
+ +
+ ); } export default App diff --git a/src/api/patients.ts b/src/api/patients.ts new file mode 100644 index 0000000..b29d73d --- /dev/null +++ b/src/api/patients.ts @@ -0,0 +1,116 @@ +const BASE_URL = import.meta.env.VITE_BASE_URL as string; +const API_KEY = import.meta.env.VITE_API_KEY as string; + +export interface Patient { + patient_id: string, + name: string, + age: number | null, + gender: string, + blood_pressure: string + temperature: number | null, + visit_date: string + diagnosis: string + medications: string +}; + +export interface Pagination { + page: number + limit: number + total: number + totalPages: number + hasNext: boolean + hasPrevious: boolean +}; + +export interface PatientsResponse { + data: Patient[] + pagination: Pagination +}; + +export class ApiError extends Error { + readonly status: number + constructor(status: number, message: string) { + super(message) + this.name = 'ApiError' + this.status = status + }; +}; + +const normalizePatient = (raw: Record): Patient => { + return { + patient_id: String(raw.patient_id ?? raw.id ?? ''), + name: String(raw.name ?? raw.patient_name ?? 'Unknown'), + age: raw.age != null ? Number(raw.age): null, + gender: String(raw.gender ?? raw.sex ?? ''), + blood_pressure: String(raw.blood_pressure ?? raw.bp ?? ''), + temperature: raw.temperature != null ? Number(raw.temperature) : null, + visit_date: String(raw.visit_date ?? raw.date ?? ''), + diagnosis: String(raw.diagnosis ?? raw.condition ?? ''), + medications: String(raw.medications ?? raw.meds ?? ''), + }; +}; + +const normalizePagination = ( + raw: Record, + page: number, + limit: number, +): Pagination => { + return { + page: Number(raw.page ?? page), + limit: Number(raw.limit ?? raw.per_page ?? limit), + total: Number(raw.total ?? raw.totalCount ?? raw.total_count ?? 0), + totalPages: Number(raw.totalPages ?? raw.total_pages ?? raw.pages ?? 1), + hasNext: Boolean(raw.hasNext ?? raw.has_next ?? false), + hasPrevious: Boolean(raw.hasPrevious ?? raw.has_previous ?? false), + }; +}; + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const fetchPageWithRetry = async ( + page: number, + limit: number, + attempt= 0, +): Promise => { + try { + return await fetchPatients(page, limit); + } catch (error) { + if (error instanceof ApiError && error.status === 429 && attempt < 5) { + await delay(Math.min(1500 * 2 ** attempt, 20_000)); + return fetchPageWithRetry(page, limit, attempt + 1); + } + throw error; + } +}; + +export const fetchAllPatients = async (): Promise => { + const all: Patient[] = []; + let page = 1; + while (true) { + const result = await fetchPageWithRetry(page, 10); + all.push(...result.data); + if (!result.pagination.hasNext) break + page++; + await delay(500); + } + return all; +}; + +export const fetchPatients = async ( + page = 1, + limit = 10, +): Promise => { + const url = `${BASE_URL}/api/patients?page=${page}&limit=${limit}`; + const res = await fetch(url, { + headers: { 'x-api-key': API_KEY } + }); + if (!res.ok) + throw new ApiError(res.status, `API error ${res.status}: ${res.statusText}`) + const json = await res.json(); + const rawData = json.data ?? json.patients ?? json.results ?? []; + const rawPagination = json.pagination ?? json.meta ?? json.paging ?? {}; + return { + data: Array.isArray(rawData) ? rawData.map(normalizePatient) : [], + pagination: normalizePagination(rawPagination, page, limit), + }; +}; diff --git a/src/api/submit.ts b/src/api/submit.ts new file mode 100644 index 0000000..8dd0e2f --- /dev/null +++ b/src/api/submit.ts @@ -0,0 +1,52 @@ +import { ApiError } from './patients' +import type { SubmissionPayload } from '../lib/scoring' + +const BASE_URL = import.meta.env.VITE_BASE_URL as string +const API_KEY = import.meta.env.VITE_API_KEY as string + +interface BreakdownCategory { + score: number; + max: number; + correct: number; + submitted: number; + matches: number; +}; + +export interface SubmissionResponse { + success: boolean; + message: string; + results: { + score: number; + percentage: number; + status: string; + breakdown: { + high_risk: BreakdownCategory + fever: BreakdownCategory + data_quality: BreakdownCategory + }; + feedback: { + strengths: string[]; + issues: string[]; + }; + attempt_number: number; + remaining_attempts: number; + is_personal_best: boolean; + can_resubmit: boolean; + }; +}; + +export const submitAssessment = async ( + payload: SubmissionPayload +): Promise => { + const res = await fetch(`${BASE_URL}/api/submit-assessment`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': API_KEY, + }, + body: JSON.stringify(payload), + }); + if (!res.ok) + throw new ApiError(res.status, `Submission failed: ${res.status} ${res.statusText}`) + return res.json() as Promise +}; diff --git a/src/assets/hero.png b/src/assets/hero.png deleted file mode 100644 index cc51a3d..0000000 Binary files a/src/assets/hero.png and /dev/null differ diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/assets/vite.svg b/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/src/components/AlertsPanel.tsx b/src/components/AlertsPanel.tsx new file mode 100644 index 0000000..3cdc766 --- /dev/null +++ b/src/components/AlertsPanel.tsx @@ -0,0 +1,73 @@ +import type { SubmissionPayload } from '../lib/scoring'; + +const AlertCard = ({ + title, + ids, + variant, + loading, + error, +}: { + title: string; + ids: string[]; + variant: 'danger' | 'warning' | 'info'; + loading: boolean; + error?: Error | null; +}) => { + return ( +
+
+ {title} + {!loading && !error && {ids.length}} +
+ { loading ? ( +

Loading...

+ ): error ? ( +

{error.message}

+ ): ids.length === 0 ? ( +

None

+ ): ( +
    + {ids.map((id) => ( +
  • {id}
  • + ))} +
+ )} +
+ ); +}; + +export const AlertsPanel = ({ + alerts, + loading, + error, +}: { + alerts: SubmissionPayload; + loading: boolean; + error?: Error | null; +}) => { + return ( +
+ + + +
+ ); +}; diff --git a/src/components/PatientTable.tsx b/src/components/PatientTable.tsx new file mode 100644 index 0000000..a4069ca --- /dev/null +++ b/src/components/PatientTable.tsx @@ -0,0 +1,85 @@ +import type { Patient } from '../api/patients' +import { scorePatient } from '../lib/scoring' + +const RiskBadge = ({ score }: { score: number}) => { + const level = score >= 4 ? 'high' : score >= 2 ? 'medium' : 'low'; + return {score} +}; + +export const PatientTable = ({ + patients, + page, + totalPages, + totalPatients, + onPageChange, +}: { + patients: Patient[]; + page: number; + totalPages: number; + totalPatients: number; + onPageChange: (page: number) => void; +}) => { + return ( + <> +
+ + + + + + + + + + + + + + + + + {patients.map((patient) => { + const score = scorePatient(patient); + return ( + + + + + + + + + + + + + ); + })} + +
IDNameAgeGenderBlood PressureTempVisitedDiagnosisMedicationsRisk
{patient.patient_id}{patient.name}{patient.age ?? -}{patient.gender}{patient.blood_pressure}{patient.temperature}{patient.visit_date}{patient.diagnosis}{patient.medications} + + {score.hasDataIssue && ( + ! + )} +
+
+
+ + + Page {page} of {totalPages} ({totalPatients} total) + + +
+ + ); +}; diff --git a/src/components/SubmissionPanel.tsx b/src/components/SubmissionPanel.tsx new file mode 100644 index 0000000..146fb43 --- /dev/null +++ b/src/components/SubmissionPanel.tsx @@ -0,0 +1,130 @@ +import { useSubmitAssessment } from '../hooks' +import type { SubmissionPayload } from '../lib/scoring' +import type { SubmissionResponse } from '../api/submit' + +const PayloadPreview = ({ payload }: { payload: SubmissionPayload }) => { + const rows: { label: string; key: keyof SubmissionPayload; variant: string }[] = [ + { label: 'High Risk Patients', key: 'high_risk_patients', variant: 'danger'}, + { label: 'Fever Patients', key: 'fever_patients', variant: 'warning'}, + { label: 'Data Quality Issue Patients', key: 'data_quality_issues', variant: 'info'}, + ]; + return ( +
+ {rows.map(({ label, key, variant }) => ( +
+
+ {label} + {payload[key].length} +
+
+ {payload[key].length === 0 ? ( + [] + ): ( + payload[key].map((id) => {id}) + )} +
+
+ ))} +
+ ); +}; + +const SubmissionResult = ({ result }: { result: SubmissionResponse }) => { + const { results } = result; + const isPassing = results.status === 'PASS'; + return ( +
+
+
+ {results.percentage}% + + {results.status} + +
+
+ Attempt {results.attempt_number} + + {results.remaining_attempts} attempt{results.remaining_attempts === 1 ? 's': ''} remaining + +
+
+ + + + + + + + + + + + {( + [ + ['High Risk', results.breakdown.high_risk], + ['Fever', results.breakdown.fever], + ['Data Quality', results.breakdown.data_quality], + ] as const + ).map(([label, cat]) => ( + + + + + + + + ))} + +
CategoryScoreCorrectSubmittedMatches
{label}{cat.score} / {cat.max}{cat.correct}{cat.submitted}{cat.matches}
+
+ ); +}; + +export const SubmissionPanel = ({ + payload, + loading: analysisLoading, +}: { + payload: SubmissionPayload; + loading: boolean; +}) => { + const { + mutate, + isPending, + isSuccess, + isError, + error, + data, + } = useSubmitAssessment(); + const totalItems = + payload.high_risk_patients.length + + payload.fever_patients.length + + payload.data_quality_issues.length + return ( +
+
+

Submit Assessment

+

+ Review payload before submitting. +

+
+ {analysisLoading ? ( +

Loading patient data...

+ ): ( + + )} +
+ + {isError && ( +

{(error as Error).message}

+ )} +
+ {isSuccess && data && } +
+ ); +}; diff --git a/src/components/index.tsx b/src/components/index.tsx new file mode 100644 index 0000000..bdb614c --- /dev/null +++ b/src/components/index.tsx @@ -0,0 +1,3 @@ +export { AlertsPanel } from './AlertsPanel'; +export { PatientTable } from './PatientTable'; +export { SubmissionPanel } from './SubmissionPanel'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..325576e --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,4 @@ +export { useAllPatients } from './useAllPatients'; +export { usePatientRiskAnalysis } from './usePatientRiskAnalysis'; +export { usePatients } from './usePatients'; +export { useSubmitAssessment } from './useSubmitAssessment'; diff --git a/src/hooks/useAllPatients.ts b/src/hooks/useAllPatients.ts new file mode 100644 index 0000000..109c26b --- /dev/null +++ b/src/hooks/useAllPatients.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' +import { fetchAllPatients } from '../api/patients' +import { apiRetryConfig } from '../lib/queryConfig' + +export const useAllPatients = () => useQuery({ + queryKey: ['patients', 'all'], + queryFn: fetchAllPatients, + staleTime: 5 * 60_000, + gcTime: 10 * 60_000, + ...apiRetryConfig, +}); diff --git a/src/hooks/usePatientRiskAnalysis.ts b/src/hooks/usePatientRiskAnalysis.ts new file mode 100644 index 0000000..42c615c --- /dev/null +++ b/src/hooks/usePatientRiskAnalysis.ts @@ -0,0 +1,15 @@ +import { useAllPatients } from './useAllPatients' +import { scorePatient, computeSubmissionPayload } from '../lib/scoring' + +export const usePatientRiskAnalysis = () => { + const { data, isLoading, isError, error } = useAllPatients(); + const patients = data ?? []; + const submissionPayload = computeSubmissionPayload(patients); + return { + isLoading, + isError, + error, + scores: patients.map(scorePatient), + submissionPayload, + }; +}; diff --git a/src/hooks/usePatients.ts b/src/hooks/usePatients.ts new file mode 100644 index 0000000..41824c2 --- /dev/null +++ b/src/hooks/usePatients.ts @@ -0,0 +1,11 @@ +import { useQuery, keepPreviousData } from '@tanstack/react-query'; +import { fetchPatients } from '../api/patients'; +import { apiRetryConfig } from '../lib/queryConfig'; + +export const usePatients = (page = 1, limit = 10) => useQuery({ + queryKey: ['patients', page, limit], + queryFn: () => fetchPatients(page, limit), + placeholderData: keepPreviousData, + staleTime: 30_000, + ...apiRetryConfig, +}); diff --git a/src/hooks/useSubmitAssessment.ts b/src/hooks/useSubmitAssessment.ts new file mode 100644 index 0000000..48fe36c --- /dev/null +++ b/src/hooks/useSubmitAssessment.ts @@ -0,0 +1,4 @@ +import { useMutation } from '@tanstack/react-query'; +import { submitAssessment } from '../api/submit'; + +export const useSubmitAssessment = () => useMutation({ mutationFn: submitAssessment }); diff --git a/src/index.css b/src/index.css index 5fb3313..4bbeffa 100644 --- a/src/index.css +++ b/src/index.css @@ -51,14 +51,8 @@ } #root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); + width: 100%; min-height: 100svh; - display: flex; - flex-direction: column; box-sizing: border-box; } diff --git a/src/lib/queryConfig.ts b/src/lib/queryConfig.ts new file mode 100644 index 0000000..9cfccf6 --- /dev/null +++ b/src/lib/queryConfig.ts @@ -0,0 +1,15 @@ +import { ApiError } from '../api/patients' + +export const apiRetryConfig = { + retry: (failureCount: number, error: Error) => { + if (error instanceof ApiError && error.status === 429) return failureCount < 5 + if (error instanceof ApiError && error.status >= 400 && error.status < 500) return false + return failureCount < 3 + }, + retryDelay: (attemptIndex: number, error: Error) => { + if (error instanceof ApiError && error.status === 429) { + return Math.min(2000 * 2 ** attemptIndex, 30_000) + } + return Math.min(1000 * 2 ** attemptIndex, 10_000) + }, +}; diff --git a/src/lib/scoring.ts b/src/lib/scoring.ts new file mode 100644 index 0000000..1f0a09a --- /dev/null +++ b/src/lib/scoring.ts @@ -0,0 +1,94 @@ +import type { Patient } from '../api/patients'; + +export interface PatientRiskScore { + patient_id: string; + bpScore: number; + tempScore: number; + ageScore: number; + totalScore: number; + bpValid: boolean; + tempValid: boolean; + ageValid: boolean; + hasDataIssue: boolean; +}; + +export interface SubmissionPayload { + high_risk_patients: string[]; + fever_patients: string[]; + data_quality_issues: string[]; +}; + +const scoreSystolic = (value: number): number => { + if (value < 120) return 0; + if (value < 130) return 1; + if (value < 140) return 2; + return 3; +}; + +const scoreDiastolic = (value: number): number => { + if (value < 80) return 0; + if (value < 90) return 2; + return 3; +}; + +export const scoreBP = (bp: string | null | undefined): { score: number; valid: boolean } => { + if (!bp) return { score: 0, valid: false }; + const parts = bp.split('/'); + if (parts.length !== 2) return { score: 0, valid: false }; + const [sysStr, diaStr] = parts; + if (!sysStr.trim() || !diaStr.trim()) return { score: 0, valid: false }; + const sys = Number(sysStr.trim()), dia = Number(diaStr.trim()); + if (!Number.isFinite(sys) || !Number.isFinite(dia)) return { score: 0, valid: false }; + return { score: Math.max(scoreSystolic(sys), scoreDiastolic(dia)), valid: true }; +}; + +export const scoreTemp = (temp: number | null | undefined): { score: number; valid: boolean } => { + if (temp == null || !Number.isFinite(temp)) return { score: 0, valid: false }; + if (temp <= 99.5) return { score: 0, valid: true }; + if (temp <= 100.9) return { score: 1, valid: true }; + return { score: 2, valid: true }; +}; + +export const scoreAge = (age: number | null | undefined): { score: number; valid: boolean } => { + if (age == null || !Number.isFinite(age)) return { score: 0, valid: false }; + if (age < 40) return { score: 0, valid: true }; + if (age <= 65) return { score: 1, valid: true }; + return { score: 2, valid: true }; +}; + +export const scorePatient = (patient: Patient): PatientRiskScore => { + const patient_id = patient.patient_id; + const { score: bpScore, valid: bpValid } = scoreBP(patient.blood_pressure); + const { score: tempScore, valid: tempValid } = scoreTemp(patient.temperature); + const { score: ageScore, valid: ageValid } = scoreAge(patient.age); + const totalScore = bpScore + tempScore + ageScore; + const hasDataIssue = !bpValid || !tempValid || !ageValid; + return { + patient_id, + bpScore, + tempScore, + ageScore, + totalScore, + bpValid, + tempValid, + ageValid, + hasDataIssue + }; +}; + +export const computeSubmissionPayload = (patients: Patient[]): SubmissionPayload => { + const high_risk_patients: string[] = []; + const fever_patients: string[] = []; + const data_quality_issues: string[] = []; + + for (const patient of patients) { + const score = scorePatient(patient); + if (score.totalScore >= 4) + high_risk_patients.push(patient.patient_id) + if (score.tempValid && patient.temperature != null && patient.temperature >= 99.6) + fever_patients.push(patient.patient_id) + if (score.hasDataIssue) + data_quality_issues.push(patient.patient_id); + } + return { high_risk_patients, fever_patients, data_quality_issues }; +}; diff --git a/src/main.tsx b/src/main.tsx index bef5202..849fd6a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,15 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import './index.css' import App from './App.tsx' +const queryClient = new QueryClient(); + createRoot(document.getElementById('root')!).render( - + + + , )