From 11fb43481b902fed05349b6354be30ce82e220f6 Mon Sep 17 00:00:00 2001 From: nurudeenmuzainat Date: Tue, 3 Mar 2026 09:14:26 +0100 Subject: [PATCH 1/6] feat(api): add paginated patient history aggregation endpoint with mock fallback --- apps/api/src/app.ts | 2 + .../modules/patients/history.controller.ts | 60 +++++ .../src/modules/patients/history.service.ts | 239 ++++++++++++++++++ .../modules/patients/history.validation.ts | 13 + 4 files changed, 314 insertions(+) create mode 100644 apps/api/src/modules/patients/history.controller.ts create mode 100644 apps/api/src/modules/patients/history.service.ts create mode 100644 apps/api/src/modules/patients/history.validation.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 1cf05db..209adc9 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,6 +4,7 @@ import { config } from '@lumen/config'; import { connectDB } from './config/db'; import { startPaymentVerificationWorker } from './modules/payments/worker'; import { patientRoutes } from './modules/patients/patients.controller'; +import { patientHistoryRoutes } from "./modules/patients/history.controller"; import { auditRoutes } from './modules/audit/audit.controller'; import { auditMiddleware } from "./middlewares/audit.middleware"; import { requireActiveSubscription } from "./middlewares/subscription.middleware"; @@ -26,6 +27,7 @@ app.use('/api/v1/clinics', clinicSettingsRoutes); app.use('/api/v1/users', userRoutes); app.use('/api/v1/payments', paymentRoutes); app.use('/api/v1/patients', patientRoutes); +app.use('/api/v1/patients', patientHistoryRoutes); app.use('/api/v1/audit-logs', auditRoutes); diff --git a/apps/api/src/modules/patients/history.controller.ts b/apps/api/src/modules/patients/history.controller.ts new file mode 100644 index 0000000..6e74a4f --- /dev/null +++ b/apps/api/src/modules/patients/history.controller.ts @@ -0,0 +1,60 @@ +import { Request, Response, Router } from "express"; +import { authorize, Roles } from "../../middlewares/rbac.middleware"; +import { validateRequest } from "../../middlewares/validate.middleware"; +import { getPatientHistory } from "./history.service"; +import { + PatientHistoryParamsDto, + PatientHistoryQueryDto, + patientHistoryParamsSchema, + patientHistoryQuerySchema, +} from "./history.validation"; + +const router = Router(); +const ALL_ROLES: Roles[] = Object.values(Roles); + +type PatientHistoryRequest = Request< + PatientHistoryParamsDto, + unknown, + unknown, + Record +>; + +router.get( + "/:id/history", + authorize(ALL_ROLES), + validateRequest({ params: patientHistoryParamsSchema, query: patientHistoryQuerySchema }), + async (req: PatientHistoryRequest, res: Response) => { + const clinicId = req.user?.clinicId; + if (!clinicId) { + return res.status(401).json({ + error: "Unauthorized", + message: "Authentication required", + }); + } + + const query = req.query as PatientHistoryQueryDto; + + const payload = await getPatientHistory({ + patientId: req.params.id, + clinicId, + page: query.page, + limit: query.limit, + }); + + if (!payload) { + return res.status(404).json({ + error: "NotFound", + message: "Patient not found", + }); + } + + return res.json({ + status: "success", + data: payload.patient, + encounters: payload.encounters, + meta: payload.meta, + }); + }, +); + +export const patientHistoryRoutes = router; diff --git a/apps/api/src/modules/patients/history.service.ts b/apps/api/src/modules/patients/history.service.ts new file mode 100644 index 0000000..0030f28 --- /dev/null +++ b/apps/api/src/modules/patients/history.service.ts @@ -0,0 +1,239 @@ +import mongoose from "mongoose"; +import { PatientModel } from "./models/patient.model"; + +type Pagination = { + page: number; + limit: number; + skip: number; +}; + +export const toPagination = (page: number, limit: number): Pagination => ({ + page, + limit, + skip: (page - 1) * limit, +}); + +const iso = (date: Date) => date.toISOString(); + +const makeMockEncounter = ( + id: string, + openedAt: Date, + closedAt: Date, + summary: string, +) => ({ + id, + status: "CLOSED", + openedAt: iso(openedAt), + closedAt: iso(closedAt), + providerId: "mock-provider-1", + vitals: [ + { + id: `${id}-v1`, + timestamp: iso(new Date(openedAt.getTime() + 20 * 60_000)), + bpSystolic: 122, + bpDiastolic: 80, + heartRate: 88, + temperature: 37, + respirationRate: 16, + spO2: 98, + weight: 72, + }, + ], + notes: [ + { + id: `${id}-n1`, + type: "SOAP", + authorId: "mock-provider-1", + timestamp: iso(new Date(openedAt.getTime() + 35 * 60_000)), + content: summary, + }, + ], + diagnoses: [ + { + id: `${id}-d1`, + code: "B50.9", + description: "Malaria, unspecified", + status: "CONFIRMED", + }, + ], +}); + +export const buildMockHistoryEncounters = () => { + const now = Date.now(); + + return [ + makeMockEncounter( + "mock-enc-3003", + new Date(now - 7 * 24 * 60 * 60_000), + new Date(now - 7 * 24 * 60 * 60_000 + 70 * 60_000), + "Patient improved after antimalarial dose.", + ), + makeMockEncounter( + "mock-enc-3002", + new Date(now - 21 * 24 * 60 * 60_000), + new Date(now - 21 * 24 * 60 * 60_000 + 65 * 60_000), + "Follow-up visit with reduced fever.", + ), + makeMockEncounter( + "mock-enc-3001", + new Date(now - 38 * 24 * 60 * 60_000), + new Date(now - 38 * 24 * 60 * 60_000 + 95 * 60_000), + "Initial presentation with high fever and chills.", + ), + ]; +}; + +export const getPatientHistory = async (input: { + patientId: string; + clinicId: string; + page: number; + limit: number; +}) => { + const patient = await PatientModel.findOne({ _id: input.patientId, clinicId: input.clinicId }).lean(); + if (!patient) { + return null; + } + + const pagination = toPagination(input.page, input.limit); + const encountersCollection = mongoose.connection.collection("encounters"); + + const total = await encountersCollection.countDocuments({ + patientId: input.patientId, + clinicId: input.clinicId, + }); + + const encounters = await encountersCollection + .aggregate([ + { + $match: { + patientId: input.patientId, + clinicId: input.clinicId, + }, + }, + { + $sort: { + openedAt: -1, + }, + }, + { + $skip: pagination.skip, + }, + { + $limit: pagination.limit, + }, + { + $lookup: { + from: "vitals", + localField: "_id", + foreignField: "encounterId", + as: "vitals", + }, + }, + { + $lookup: { + from: "clinicalnotes", + localField: "_id", + foreignField: "encounterId", + as: "notes", + }, + }, + { + $lookup: { + from: "diagnoses", + localField: "_id", + foreignField: "encounterId", + as: "diagnoses", + }, + }, + { + $project: { + id: { $toString: "$_id" }, + status: 1, + openedAt: 1, + closedAt: 1, + providerId: 1, + vitals: { + $map: { + input: "$vitals", + as: "v", + in: { + id: { $toString: "$$v._id" }, + timestamp: "$$v.timestamp", + bpSystolic: "$$v.bpSystolic", + bpDiastolic: "$$v.bpDiastolic", + heartRate: "$$v.heartRate", + temperature: "$$v.temperature", + respirationRate: "$$v.respirationRate", + spO2: "$$v.spO2", + weight: "$$v.weight", + }, + }, + }, + notes: { + $map: { + input: "$notes", + as: "n", + in: { + id: { $toString: "$$n._id" }, + type: "$$n.type", + authorId: "$$n.authorId", + timestamp: "$$n.timestamp", + content: "$$n.content", + }, + }, + }, + diagnoses: { + $map: { + input: "$diagnoses", + as: "d", + in: { + id: { $toString: "$$d._id" }, + code: "$$d.code", + description: "$$d.description", + status: "$$d.status", + }, + }, + }, + }, + }, + ]) + .toArray(); + + const mappedEncounters = encounters.map((encounter) => ({ + ...encounter, + openedAt: encounter.openedAt ? new Date(encounter.openedAt).toISOString() : null, + closedAt: encounter.closedAt ? new Date(encounter.closedAt).toISOString() : null, + vitals: (encounter.vitals ?? []).map((v: Record) => ({ + ...v, + timestamp: v.timestamp ? new Date(v.timestamp as string | Date).toISOString() : null, + })), + notes: (encounter.notes ?? []).map((n: Record) => ({ + ...n, + timestamp: n.timestamp ? new Date(n.timestamp as string | Date).toISOString() : null, + })), + })); + + const fallback = buildMockHistoryEncounters(); + const hasReal = mappedEncounters.length > 0; + + const fallbackPageSlice = fallback.slice(pagination.skip, pagination.skip + pagination.limit); + + return { + patient: { + id: String(patient._id), + systemId: patient.systemId, + firstName: patient.firstName, + lastName: patient.lastName, + sex: patient.sex, + dateOfBirth: patient.dateOfBirth.toISOString(), + }, + encounters: hasReal ? mappedEncounters : fallbackPageSlice, + meta: { + page: pagination.page, + limit: pagination.limit, + total: hasReal ? total : fallback.length, + totalPages: Math.max(1, Math.ceil((hasReal ? total : fallback.length) / pagination.limit)), + source: hasReal ? "db" : "mock", + }, + }; +}; diff --git a/apps/api/src/modules/patients/history.validation.ts b/apps/api/src/modules/patients/history.validation.ts new file mode 100644 index 0000000..809ae2e --- /dev/null +++ b/apps/api/src/modules/patients/history.validation.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const patientHistoryParamsSchema = z.object({ + id: z.string().trim().min(1), +}); + +export const patientHistoryQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(25).default(5), +}); + +export type PatientHistoryParamsDto = z.infer; +export type PatientHistoryQueryDto = z.infer; From bd703f25eea235db23e837f02fd63bd9b8ddbfc7 Mon Sep 17 00:00:00 2001 From: nurudeenmuzainat Date: Wed, 4 Mar 2026 13:33:51 +0100 Subject: [PATCH 2/6] feat(web): add longitudinal patient timeline with lazy loading and accordion encounters --- apps/web/app/dashboard/patients/[id]/page.tsx | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 apps/web/app/dashboard/patients/[id]/page.tsx diff --git a/apps/web/app/dashboard/patients/[id]/page.tsx b/apps/web/app/dashboard/patients/[id]/page.tsx new file mode 100644 index 0000000..3bfc864 --- /dev/null +++ b/apps/web/app/dashboard/patients/[id]/page.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { apiFetch } from "@/lib/api-client"; + +type TimelineVitals = { + id: string; + timestamp: string; + bpSystolic: number; + bpDiastolic: number; + heartRate: number; + temperature: number; + respirationRate: number; + spO2: number; + weight: number; +}; + +type TimelineNote = { + id: string; + type: string; + authorId: string; + timestamp: string; + content: string; +}; + +type TimelineDiagnosis = { + id: string; + code: string; + description: string; + status: string; +}; + +type TimelineEncounter = { + id: string; + status: string; + openedAt: string; + closedAt: string | null; + providerId: string; + vitals: TimelineVitals[]; + notes: TimelineNote[]; + diagnoses: TimelineDiagnosis[]; +}; + +type HistoryResponse = { + data?: { + id: string; + systemId: string; + firstName: string; + lastName: string; + sex: string; + dateOfBirth: string; + }; + encounters?: TimelineEncounter[]; + meta?: { + page: number; + limit: number; + total: number; + totalPages: number; + source: "db" | "mock"; + }; +}; + +const formatDateTime = (value: string | null) => { + if (!value) { + return "N/A"; + } + + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +}; + +const getPatientIdFromPath = () => { + const parts = window.location.pathname.split("/").filter(Boolean); + const idx = parts.findIndex((part) => part === "patients"); + if (idx === -1) { + return ""; + } + return parts[idx + 1] ?? ""; +}; + +export default function PatientTimelinePage() { + const [patientId, setPatientId] = useState(""); + const [patient, setPatient] = useState(null); + const [encounters, setEncounters] = useState([]); + const [meta, setMeta] = useState(null); + const [expandedById, setExpandedById] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [error, setError] = useState(null); + + const sentinelRef = useRef(null); + + const hasMore = useMemo(() => { + if (!meta) { + return false; + } + return meta.page < meta.totalPages; + }, [meta]); + + const loadPage = async (id: string, page: number, append: boolean) => { + if (!id) { + return; + } + + if (append) { + setIsLoadingMore(true); + } else { + setIsLoading(true); + setError(null); + } + + try { + const response = await apiFetch(`/patients/${id}/history?page=${page}&limit=5`); + if (!response.ok) { + throw new Error("Failed to load timeline"); + } + + const payload = (await response.json()) as HistoryResponse; + if (!payload.data) { + throw new Error("Invalid history payload"); + } + + setPatient(payload.data); + setMeta(payload.meta ?? null); + + const newEncounters = payload.encounters ?? []; + if (append) { + setEncounters((current) => { + const existing = new Set(current.map((entry) => entry.id)); + const deduped = newEncounters.filter((entry) => !existing.has(entry.id)); + return [...current, ...deduped]; + }); + } else { + setEncounters(newEncounters); + } + } catch { + setError("Unable to load patient history timeline."); + if (!append) { + setEncounters([]); + } + } finally { + setIsLoading(false); + setIsLoadingMore(false); + } + }; + + useEffect(() => { + const id = getPatientIdFromPath(); + setPatientId(id); + if (id) { + void loadPage(id, 1, false); + } + }, []); + + useEffect(() => { + if (!sentinelRef.current || !hasMore || !meta || !patientId) { + return; + } + + const observer = new IntersectionObserver((entries) => { + const first = entries[0]; + if (!first.isIntersecting || isLoadingMore) { + return; + } + + void loadPage(patientId, meta.page + 1, true); + }); + + observer.observe(sentinelRef.current); + + return () => observer.disconnect(); + }, [hasMore, isLoadingMore, meta, patientId]); + + return ( +
+
+

Patient Timeline

+ {patient ? ( +

+ {patient.firstName} {patient.lastName} ({patient.systemId}) +

+ ) : null} + {meta ? ( +

+ Source: {meta.source.toUpperCase()} • Loaded {encounters.length} of {meta.total} encounters +

+ ) : null} +
+ + {isLoading ? ( +
+ Loading timeline... +
+ ) : error ? ( +
+ {error} +
+ ) : encounters.length === 0 ? ( +
+ No historical encounters found. +
+ ) : ( +
+
+ +
+ {encounters.map((encounter) => { + const expanded = expandedById[encounter.id] ?? false; + return ( +
+ + + + + {expanded ? ( +
+
+

Vitals

+
+ {encounter.vitals.length === 0 ? ( +

No vitals recorded.

+ ) : ( + encounter.vitals.map((vital) => ( +
+ {formatDateTime(vital.timestamp)} • BP {vital.bpSystolic}/{vital.bpDiastolic} • HR {vital.heartRate} • Temp {vital.temperature}°C • SpO2 {vital.spO2}% +
+ )) + )} +
+
+ +
+

Notes

+
+ {encounter.notes.length === 0 ? ( +

No notes recorded.

+ ) : ( + encounter.notes.map((note) => ( +
+

{note.type}

+

{note.content}

+
+ )) + )} +
+
+ +
+

Diagnoses

+
+ {encounter.diagnoses.length === 0 ? ( +

No diagnoses linked.

+ ) : ( + encounter.diagnoses.map((diagnosis) => ( +
+ {diagnosis.code} - {diagnosis.description} ({diagnosis.status}) +
+ )) + )} +
+
+
+ ) : null} +
+ ); + })} +
+ +
+ + {isLoadingMore ? ( +

Loading more encounters...

+ ) : hasMore ? ( +

Scroll to load more encounters

+ ) : ( +

End of timeline

+ )} +
+ )} +
+ ); +} From 0343183cd470bbabde0adb4766663cef409a4e16 Mon Sep 17 00:00:00 2001 From: nurudeenmuzainat Date: Thu, 5 Mar 2026 10:52:09 +0100 Subject: [PATCH 3/6] test(api): add patient history helper coverage for pagination and mock ordering --- apps/api/tests/history.service.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 apps/api/tests/history.service.test.ts diff --git a/apps/api/tests/history.service.test.ts b/apps/api/tests/history.service.test.ts new file mode 100644 index 0000000..ae3846b --- /dev/null +++ b/apps/api/tests/history.service.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { buildMockHistoryEncounters, toPagination } from "../src/modules/patients/history.service"; + +describe("patient history helpers", () => { + it("calculates pagination offsets", () => { + const pagination = toPagination(3, 5); + expect(pagination).toEqual({ page: 3, limit: 5, skip: 10 }); + }); + + it("returns mock encounters sorted newest to oldest", () => { + const rows = buildMockHistoryEncounters(); + expect(rows.length).toBe(3); + + const openedTimes = rows.map((row) => new Date(row.openedAt).getTime()); + expect(openedTimes[0]).toBeGreaterThan(openedTimes[1]); + expect(openedTimes[1]).toBeGreaterThan(openedTimes[2]); + }); +}); From b0742b3ce0443d0fad769d5a9822c3fb7783f119 Mon Sep 17 00:00:00 2001 From: Muzainat Date: Sun, 8 Mar 2026 22:54:09 +0100 Subject: [PATCH 4/6] Update history.controller.ts --- apps/api/src/modules/patients/history.controller.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/api/src/modules/patients/history.controller.ts b/apps/api/src/modules/patients/history.controller.ts index 6e74a4f..427fc08 100644 --- a/apps/api/src/modules/patients/history.controller.ts +++ b/apps/api/src/modules/patients/history.controller.ts @@ -16,13 +16,16 @@ type PatientHistoryRequest = Request< PatientHistoryParamsDto, unknown, unknown, - Record + PatientHistoryQueryDto >; router.get( "/:id/history", authorize(ALL_ROLES), - validateRequest({ params: patientHistoryParamsSchema, query: patientHistoryQuerySchema }), + validateRequest({ + params: patientHistoryParamsSchema, + query: patientHistoryQuerySchema, + }), async (req: PatientHistoryRequest, res: Response) => { const clinicId = req.user?.clinicId; if (!clinicId) { @@ -32,13 +35,11 @@ router.get( }); } - const query = req.query as PatientHistoryQueryDto; - const payload = await getPatientHistory({ patientId: req.params.id, clinicId, - page: query.page, - limit: query.limit, + page: req.query.page, + limit: req.query.limit, }); if (!payload) { From ec39dd2fd2887f4b61e8811b493c17c3b9174d1b Mon Sep 17 00:00:00 2001 From: Muzainat Date: Sun, 8 Mar 2026 23:00:43 +0100 Subject: [PATCH 5/6] Update page.tsx --- apps/web/app/dashboard/patients/[id]/page.tsx | 438 +++++++++--------- 1 file changed, 217 insertions(+), 221 deletions(-) diff --git a/apps/web/app/dashboard/patients/[id]/page.tsx b/apps/web/app/dashboard/patients/[id]/page.tsx index 3bfc864..29ce0c4 100644 --- a/apps/web/app/dashboard/patients/[id]/page.tsx +++ b/apps/web/app/dashboard/patients/[id]/page.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useParams } from "next/navigation"; import { apiFetch } from "@/lib/api-client"; -type TimelineVitals = { +type HistoryVitals = { id: string; timestamp: string; bpSystolic: number; @@ -15,166 +16,164 @@ type TimelineVitals = { weight: number; }; -type TimelineNote = { +type HistoryNote = { id: string; - type: string; - authorId: string; timestamp: string; + authorId: string; + type: "SOAP" | "FREE_TEXT" | "AI_SUMMARY" | "CORRECTION"; content: string; + correctionOfNoteId?: string; }; -type TimelineDiagnosis = { +type HistoryDiagnosis = { id: string; code: string; description: string; - status: string; + status: "SUSPECTED" | "CONFIRMED" | "RESOLVED"; }; -type TimelineEncounter = { +type HistoryEncounter = { id: string; - status: string; + status: "OPEN" | "IN_PROGRESS" | "CLOSED"; openedAt: string; closedAt: string | null; - providerId: string; - vitals: TimelineVitals[]; - notes: TimelineNote[]; - diagnoses: TimelineDiagnosis[]; + vitals: HistoryVitals[]; + notes: HistoryNote[]; + diagnoses: HistoryDiagnosis[]; +}; + +type HistoryPatient = { + id: string; + systemId: string; + firstName: string; + lastName: string; + sex: "M" | "F" | "O"; + dateOfBirth: string; + isActive: boolean; }; type HistoryResponse = { - data?: { - id: string; - systemId: string; - firstName: string; - lastName: string; - sex: string; - dateOfBirth: string; - }; - encounters?: TimelineEncounter[]; - meta?: { + status: "success"; + data: HistoryPatient; + encounters: HistoryEncounter[]; + meta: { page: number; limit: number; total: number; - totalPages: number; - source: "db" | "mock"; + hasNextPage: boolean; }; }; -const formatDateTime = (value: string | null) => { - if (!value) { - return "N/A"; - } +const PAGE_SIZE = 5; - return new Intl.DateTimeFormat("en-US", { +const formatDateTime = (value: string) => + new Date(value).toLocaleString(undefined, { + year: "numeric", month: "short", day: "2-digit", - year: "numeric", hour: "2-digit", minute: "2-digit", - }).format(new Date(value)); -}; + }); -const getPatientIdFromPath = () => { - const parts = window.location.pathname.split("/").filter(Boolean); - const idx = parts.findIndex((part) => part === "patients"); - if (idx === -1) { - return ""; - } - return parts[idx + 1] ?? ""; +const statusBadgeClass = (status: HistoryDiagnosis["status"]) => { + if (status === "CONFIRMED") return "border-green-200 bg-green-50 text-green-700"; + if (status === "RESOLVED") return "border-slate-200 bg-slate-50 text-slate-700"; + return "border-amber-200 bg-amber-50 text-amber-700"; }; -export default function PatientTimelinePage() { - const [patientId, setPatientId] = useState(""); - const [patient, setPatient] = useState(null); - const [encounters, setEncounters] = useState([]); - const [meta, setMeta] = useState(null); - const [expandedById, setExpandedById] = useState>({}); - const [isLoading, setIsLoading] = useState(true); +export default function PatientHistoryPage() { + const params = useParams<{ id: string }>(); + const patientId = params?.id ?? ""; + + const [patient, setPatient] = useState(null); + const [encounters, setEncounters] = useState([]); + const [page, setPage] = useState(1); + const [hasNextPage, setHasNextPage] = useState(true); + const [isInitialLoading, setIsInitialLoading] = useState(true); const [isLoadingMore, setIsLoadingMore] = useState(false); const [error, setError] = useState(null); + const [expanded, setExpanded] = useState>({}); - const sentinelRef = useRef(null); + const loaderRef = useRef(null); + const inFlightRef = useRef(false); - const hasMore = useMemo(() => { - if (!meta) { - return false; - } - return meta.page < meta.totalPages; - }, [meta]); + const loadPage = useCallback( + async (nextPage: number, replace = false) => { + if (!patientId || inFlightRef.current) return; + inFlightRef.current = true; + setError(null); - const loadPage = async (id: string, page: number, append: boolean) => { - if (!id) { - return; - } + if (replace) setIsInitialLoading(true); + else setIsLoadingMore(true); - if (append) { - setIsLoadingMore(true); - } else { - setIsLoading(true); - setError(null); - } + try { + const res = await apiFetch( + `/patients/${encodeURIComponent(patientId)}/history?page=${nextPage}&limit=${PAGE_SIZE}`, + ); - try { - const response = await apiFetch(`/patients/${id}/history?page=${page}&limit=5`); - if (!response.ok) { - throw new Error("Failed to load timeline"); - } + if (!res.ok) { + if (res.status === 404) throw new Error("Patient not found."); + throw new Error("Unable to load patient history."); + } - const payload = (await response.json()) as HistoryResponse; - if (!payload.data) { - throw new Error("Invalid history payload"); - } + const payload = (await res.json()) as HistoryResponse; - setPatient(payload.data); - setMeta(payload.meta ?? null); + setPatient(payload.data); + setHasNextPage(payload.meta.hasNextPage); + setPage(payload.meta.page); - const newEncounters = payload.encounters ?? []; - if (append) { setEncounters((current) => { - const existing = new Set(current.map((entry) => entry.id)); - const deduped = newEncounters.filter((entry) => !existing.has(entry.id)); - return [...current, ...deduped]; + if (replace) return payload.encounters; + + const existing = new Map(current.map((item) => [item.id, item])); + for (const encounter of payload.encounters) { + existing.set(encounter.id, encounter); + } + return Array.from(existing.values()).sort( + (a, b) => new Date(b.openedAt).getTime() - new Date(a.openedAt).getTime(), + ); }); - } else { - setEncounters(newEncounters); + } catch (err) { + setError((err as Error).message || "Unable to load patient history."); + } finally { + inFlightRef.current = false; + setIsInitialLoading(false); + setIsLoadingMore(false); } - } catch { - setError("Unable to load patient history timeline."); - if (!append) { - setEncounters([]); - } - } finally { - setIsLoading(false); - setIsLoadingMore(false); - } - }; + }, + [patientId], + ); useEffect(() => { - const id = getPatientIdFromPath(); - setPatientId(id); - if (id) { - void loadPage(id, 1, false); - } - }, []); + setPatient(null); + setEncounters([]); + setExpanded({}); + setHasNextPage(true); + setPage(1); + void loadPage(1, true); + }, [loadPage]); useEffect(() => { - if (!sentinelRef.current || !hasMore || !meta || !patientId) { - return; - } - - const observer = new IntersectionObserver((entries) => { - const first = entries[0]; - if (!first.isIntersecting || isLoadingMore) { - return; - } - - void loadPage(patientId, meta.page + 1, true); - }); - - observer.observe(sentinelRef.current); - + if (!loaderRef.current || !hasNextPage) return; + + const target = loaderRef.current; + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry?.isIntersecting || inFlightRef.current || isInitialLoading) return; + void loadPage(page + 1, false); + }, + { rootMargin: "200px 0px" }, + ); + + observer.observe(target); return () => observer.disconnect(); - }, [hasMore, isLoadingMore, meta, patientId]); + }, [hasNextPage, isInitialLoading, loadPage, page]); + + const fullName = useMemo(() => { + if (!patient) return ""; + return `${patient.firstName} ${patient.lastName}`.trim(); + }, [patient]); return (
@@ -182,125 +181,122 @@ export default function PatientTimelinePage() {

Patient Timeline

{patient ? (

- {patient.firstName} {patient.lastName} ({patient.systemId}) + {fullName} ({patient.systemId}) · {patient.sex}

- ) : null} - {meta ? ( -

- Source: {meta.source.toUpperCase()} • Loaded {encounters.length} of {meta.total} encounters -

- ) : null} + ) : ( +

Loading patient profile...

+ )} - {isLoading ? ( -
- Loading timeline... -
- ) : error ? ( -
- {error} + {error ? ( +
{error}
+ ) : null} + + {isInitialLoading ? ( +
+ Loading medical history...
) : encounters.length === 0 ? ( -
- No historical encounters found. +
+ No encounter history found.
) : ( -
-
- -
- {encounters.map((encounter) => { - const expanded = expandedById[encounter.id] ?? false; - return ( -
- - - - - {expanded ? ( -
-
-

Vitals

-
- {encounter.vitals.length === 0 ? ( -

No vitals recorded.

- ) : ( - encounter.vitals.map((vital) => ( -
- {formatDateTime(vital.timestamp)} • BP {vital.bpSystolic}/{vital.bpDiastolic} • HR {vital.heartRate} • Temp {vital.temperature}°C • SpO2 {vital.spO2}% -
- )) - )} +
+ {encounters.map((encounter) => { + const isOpen = expanded[encounter.id] ?? false; + return ( +
+ + + {isOpen ? ( +
+
+

Vitals

+ {encounter.vitals.length === 0 ? ( +

No vitals recorded.

+ ) : ( +
+ {encounter.vitals.map((vital) => ( +
+

{formatDateTime(vital.timestamp)}

+

+ BP {vital.bpSystolic}/{vital.bpDiastolic} · HR {vital.heartRate} · Temp{" "} + {vital.temperature}°C · RR {vital.respirationRate} · SpO2 {vital.spO2}% · Wt{" "} + {vital.weight}kg +

+
+ ))}
-
- -
-

Notes

+ )} +
+ +
+

Notes

+ {encounter.notes.length === 0 ? ( +

No notes recorded.

+ ) : (
- {encounter.notes.length === 0 ? ( -

No notes recorded.

- ) : ( - encounter.notes.map((note) => ( -
-

{note.type}

-

{note.content}

-
- )) - )} + {encounter.notes.map((note) => ( +
+

+ {note.type} · {formatDateTime(note.timestamp)} +

+

{note.content}

+
+ ))}
-
- -
-

Diagnoses

-
- {encounter.diagnoses.length === 0 ? ( -

No diagnoses linked.

- ) : ( - encounter.diagnoses.map((diagnosis) => ( -
- {diagnosis.code} - {diagnosis.description} ({diagnosis.status}) -
- )) - )} + )} +
+ +
+

Diagnoses

+ {encounter.diagnoses.length === 0 ? ( +

No diagnoses recorded.

+ ) : ( +
+ {encounter.diagnoses.map((diagnosis) => ( + + {diagnosis.code} - {diagnosis.description} ({diagnosis.status}) + + ))}
-
-
- ) : null} -
- ); - })} + )} +
+
+ ) : null} +
+ ); + })} + +
+ {isLoadingMore ? ( +

Loading more encounters...

+ ) : hasNextPage ? null : ( +

End of timeline.

+ )}
- -
- - {isLoadingMore ? ( -

Loading more encounters...

- ) : hasMore ? ( -

Scroll to load more encounters

- ) : ( -

End of timeline

- )}
)}
From 374b628d56c5f7781630f521ea00c67f68fbd71a Mon Sep 17 00:00:00 2001 From: Muzainat Date: Sun, 8 Mar 2026 23:05:32 +0100 Subject: [PATCH 6/6] Update history.controller.ts --- apps/api/src/modules/patients/history.controller.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/api/src/modules/patients/history.controller.ts b/apps/api/src/modules/patients/history.controller.ts index 427fc08..2a6713d 100644 --- a/apps/api/src/modules/patients/history.controller.ts +++ b/apps/api/src/modules/patients/history.controller.ts @@ -1,10 +1,10 @@ import { Request, Response, Router } from "express"; +import { ParsedQs } from "qs"; import { authorize, Roles } from "../../middlewares/rbac.middleware"; import { validateRequest } from "../../middlewares/validate.middleware"; import { getPatientHistory } from "./history.service"; import { PatientHistoryParamsDto, - PatientHistoryQueryDto, patientHistoryParamsSchema, patientHistoryQuerySchema, } from "./history.validation"; @@ -16,7 +16,7 @@ type PatientHistoryRequest = Request< PatientHistoryParamsDto, unknown, unknown, - PatientHistoryQueryDto + ParsedQs >; router.get( @@ -35,11 +35,13 @@ router.get( }); } + const query = patientHistoryQuerySchema.parse(req.query); + const payload = await getPatientHistory({ patientId: req.params.id, clinicId, - page: req.query.page, - limit: req.query.limit, + page: query.page, + limit: query.limit, }); if (!payload) {