Skip to content

Commit

Permalink
add patient los boxplots to dashboard (#138)
Browse files Browse the repository at this point in the history
Problem
- We want to add boxplots to the dashboard to show the length of stay for patients

Solution
- Add a new component to the dashboard that will display the boxplots
- Added unit tests for the new component
- Fixed mock data datetimes to not produce negative values

Ticket:
N/A

Documentation:
N/A

Tests
- `yarn test` passes
- `yarn build` passes
- manual testing on local dev environment
  • Loading branch information
critch646 authored Mar 23, 2024
1 parent c77a185 commit 22784ba
Show file tree
Hide file tree
Showing 10 changed files with 635 additions and 21 deletions.
10 changes: 5 additions & 5 deletions app/api/seeder/generate_patient_encounters.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,15 +213,15 @@ def generate_dates_and_times(

# Define length of stay based on triage_acuity
if triage_acuity == "white":
length_of_stay = timedelta(minutes=10)
length_of_stay = timedelta(minutes=random.randint(8, 12))
elif triage_acuity == "green":
length_of_stay = timedelta(minutes=15)
length_of_stay = timedelta(minutes=random.randint(8, 18))
elif triage_acuity == "yellow":
length_of_stay = timedelta(hours=random.randint(1, 15))
length_of_stay = timedelta(minutes=random.randint(30, 240))
elif triage_acuity == "red":
length_of_stay = timedelta(hours=random.randint(3, 20))
length_of_stay = timedelta(minutes=random.randint(30, 240))
else:
length_of_stay = timedelta(hours=random.randint(1, 4))
length_of_stay = timedelta(minutes=random.randint(10, 240))

# Calculate departure datetime
departure_date_time = arrival_date_time + length_of_stay
Expand Down
149 changes: 149 additions & 0 deletions app/web/components/dashboard/LengthOfStayComponents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import React from "react";
import { Paper, Typography } from "@mui/material";
import { BoxPlot } from "@visx/stats";
import { Group } from "@visx/group";
import { scaleBand, scaleLinear } from "@visx/scale";
import { AxisLeft, AxisBottom } from "@visx/axis";
import { BoxPlotData } from "../../interfaces/PosteventDashboard";


export interface LengthOfStayStyle {
title: string;
titleColor: string;
titleBackground: string;
boxFill: string;
boxStroke: string;
}

export const LengthOfStayWhiskerBoxPlot: React.FC<{ boxPlotData: BoxPlotData[], style: LengthOfStayStyle }> = ({ boxPlotData: dataByDay, style }) => {


// Calculate min and max for the yScale across all days
const allData = dataByDay.flatMap(day => day.data);
const min = Math.min(...allData);
const max = Math.max(...allData);

const buffer = Math.abs(max - min) * 0.1;

// Scales
const yScale = scaleLinear({
domain: [Math.max(0, min) - buffer, max + buffer],
range: [300, 0],
});
const xScale = scaleBand({
domain: dataByDay.map(day => day.day),
range: [0, 300],
padding: 0.2,
});

// Dimensions
const margin = { top: 20, right: 20, bottom: 80, left: 70 };
const width = 300 + margin.left + margin.right;
const height = 320 + margin.top + margin.bottom;

// Calculate box width with a reduction factor
const boxWidth = xScale.bandwidth() * 0.5;

// Axis labels
const xAxisLabel = "Days";
const yAxisLabel = "Length of Stay";
const axisFontSize = 18;

const medianLineWidth = 3;

return (
<Paper sx={{ width: width, height: height }} elevation={3}>
<Typography variant="h5" sx={{ textAlign: "center", fontWeight: "bold", color: style.titleColor, backgroundColor: style.titleBackground }}>{style.title}</Typography>
<svg width={width} height={height}>
<Group top={margin.top} left={margin.left}>
{dataByDay.map((dayData, i) => {
const { day, data } = dayData;
if (xScale(day) === undefined || !data) {
return null;
}
const min = Math.min(...data);
const max = Math.max(...data);
const quartiles = calculateQuartiles(data);
const leftPosition = xScale(day)! + (xScale.bandwidth() - boxWidth) / 2;

if (data.length > 1) {
const medianValue = yScale(quartiles.median);
return (
<Group key={i} left={leftPosition}>
<BoxPlot
min={min}
max={max}
firstQuartile={quartiles.firstQuartile}
thirdQuartile={quartiles.thirdQuartile}
median={quartiles.median}
boxWidth={boxWidth}
fill={style.boxFill}
stroke={style.boxStroke}
strokeWidth={2}
valueScale={yScale}
/>
{/* Custom median line */}
<line
x1={0}
x2={boxWidth}
y1={medianValue}
y2={medianValue}
stroke={style.titleColor}
strokeWidth={medianLineWidth}
/>
</Group>
);
}
})}
<AxisLeft
scale={yScale}
label={yAxisLabel}
labelProps={{
fill: "#000",
textAnchor: "middle",
fontSize: axisFontSize,
fontFamily: "Arial",
}}
labelOffset={30}
/>
<AxisBottom
top={yScale(min)}
scale={xScale}
label={xAxisLabel}
labelProps={{
fill: "#000",
textAnchor: "middle",
fontSize: axisFontSize,
fontFamily: "Arial",
}}
labelOffset={15}
/>
</Group>
</svg>
</Paper>
);

};

function calculateQuartiles(data: number[]) {
if (data.length === 1) {
return {
firstQuartile: data[0],
median: data[0],
thirdQuartile: data[0],
};
}
const sortedData = [...data].sort((a, b) => a - b);
const mid = Math.floor(sortedData.length / 2);
const isEven = sortedData.length % 2 === 0;

// If even number of data points, median is average of two middle values
const median = isEven
? (sortedData[mid] + sortedData[mid - 1]) / 2
: sortedData[mid];

const firstQuartile = sortedData[Math.floor((mid - 1) / 2)];
const thirdQuartile = sortedData[Math.ceil((sortedData.length + mid - 1) / 2)];

return { firstQuartile, median, thirdQuartile };
}
2 changes: 1 addition & 1 deletion app/web/components/dashboard/PostEventDashboardSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const PostEventDashboardSidebar: React.FC<PostEventDashboardSidebarProps> = ({ c
</FormProvider>
</ListItemText>
</ListItem>
{["Summary", "Patient Encounters", "Offsite Transports", "Patient Length of Stay Times"].map((text) => (
{["Summary", "Patient Encounters", "Offsite Transports", "Length of Stay"].map((text) => (
<ListItemButton key={text} onClick={() => onSelectView(text)} sx={{ backgroundColor: text === selectedView ? "rgba(255, 255, 255, 0.2)" : "transparent", }}>
<ListItemText primary={text} />
</ListItemButton>
Expand Down
72 changes: 68 additions & 4 deletions app/web/components/dashboard/PostFestivalDashboards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import { TopTenCommonPresentationsTableProps } from "../../interfaces/TopTenComm
import { AcuityCountsData } from "../../interfaces/AcuityCountsData";
import { AcuityCountPerDay, OffsiteTransportCountTotals, OffsiteTransportEntry } from "../../interfaces/PosteventDashboard";
import { PatientEncounterCountByDayStackedBarChart, PatientEncounterCountByDayTable, OffsiteTransportBreakdownSideBarChart, OffsiteTransportList, OffsiteTransportStackedBarChart } from "./PatientEncounterCountsByDay";
import { triageColorStyles, offsiteTransportColorStyles } from "../../constants/colorPalettes";
import { triageColorStyles, offsiteTransportColorStyles, tableColorStylesLight } from "../../constants/colorPalettes";
import { LengthOfStayWhiskerBoxPlot } from "./LengthOfStayComponents";
import { LengthOfStayDashboardProps } from "../../interfaces/PosteventDashboard";

interface PostFestivalSummaryProps {
selectedYear: string;
Expand Down Expand Up @@ -119,9 +121,70 @@ export const OffsiteTransportsDashboardComponent: React.FC<OffsiteTransportsDash
</Grid>
}

export const PatientLengthOfStayDashboardComponent = () => {
return <Box>
</Box>;

export const LengthOfStayDashboardComponent: React.FC<LengthOfStayDashboardProps> = ({ losBoxPlotData }) => {

// Styles for the box plots
const styleLosAll = {
title: "All Acuity Levels",
titleColor: tableColorStylesLight.subHeader.color,
titleBackground: tableColorStylesLight.subHeader.backgroundColor,
boxFill: tableColorStylesLight.subHeader.backgroundColor,
boxStroke: "#000000",
};

const styleLosRed = {
title: "Red Acuity Level",
titleColor: triageColorStyles.red.color,
titleBackground: triageColorStyles.red.backgroundColor,
boxFill: triageColorStyles.red.backgroundColor,
boxStroke: "#000000",
};

const styleLosYellow = {
title: "Yellow Acuity Level",
titleColor: triageColorStyles.yellow.color,
titleBackground: triageColorStyles.yellow.backgroundColor,
boxFill: triageColorStyles.yellow.backgroundColor,
boxStroke: "#000000",
};

const styleLosGreen = {
title: "Green Acuity Level",
titleColor: triageColorStyles.green.color,
titleBackground: triageColorStyles.green.backgroundColor,
boxFill: triageColorStyles.green.backgroundColor,
boxStroke: "#000000",
};

const styleLosWhite = {
title: "White Acuity Level",
titleColor: triageColorStyles.white.color,
titleBackground: triageColorStyles.white.backgroundColor,
boxFill: triageColorStyles.white.backgroundColor,
boxStroke: "#000000",
};

console.log("losBoxPlotData:", losBoxPlotData);


return <Grid container spacing={2} style={{ padding: 1 + "rem" }} >
<Grid item xs={12} md={8} lg={8} xl={4}>
<LengthOfStayWhiskerBoxPlot boxPlotData={losBoxPlotData.all} style={styleLosAll} />
</Grid>
<Grid item xs={12} md={8} lg={8} xl={4}>
<LengthOfStayWhiskerBoxPlot boxPlotData={losBoxPlotData.red} style={styleLosRed} />
</Grid>
<Grid item xs={12} md={8} lg={8} xl={4}>
<LengthOfStayWhiskerBoxPlot boxPlotData={losBoxPlotData.yellow} style={styleLosYellow} />
</Grid>
<Grid item xs={12} md={8} lg={8} xl={4}>
<LengthOfStayWhiskerBoxPlot boxPlotData={losBoxPlotData.green} style={styleLosGreen} />
</Grid>
<Grid item xs={12} md={8} lg={8} xl={4}>
<LengthOfStayWhiskerBoxPlot boxPlotData={losBoxPlotData.white} style={styleLosWhite} />
</Grid>
</Grid >
}

interface ColorStyle {
Expand Down Expand Up @@ -166,6 +229,7 @@ export const TriageAcuityLegend: React.FC<TriageAcuityLegendProps> = ({ triageCo
);
}


interface OffsiteTransportLegendProps {
transportColorStyles: { [key: string]: ColorStyle };
legendStyle?: React.CSSProperties;
Expand Down
17 changes: 17 additions & 0 deletions app/web/interfaces/PosteventDashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,20 @@ export type OffsiteTransportEntry = {
method: string;
chiefComplaint: string;
};

export interface BoxPlotData {
day: string;
data: number[];
}

export interface LengthOfStayDashboardData {
all: BoxPlotData[];
red: BoxPlotData[];
yellow: BoxPlotData[];
green: BoxPlotData[];
white: BoxPlotData[];
}

export interface LengthOfStayDashboardProps {
losBoxPlotData: LengthOfStayDashboardData;
}
3 changes: 3 additions & 0 deletions app/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@
"@visx/legend": "^3.5.0",
"@visx/scale": "^3.5.0",
"@visx/shape": "^3.5.0",
"@visx/stats": "^3.5.0",
"@visx/text": "^3.3.0",
"@visx/tooltip": "^3.3.0",
"date-fns": "^2.28.0",
"date-fns-tz": "^2.0.1",
"next": "^12.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand Down
23 changes: 17 additions & 6 deletions app/web/pages/medical/dashboards/postevent-summary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
calculateOffsiteTransportCounts,
generateOffsiteTransportList,
calculateOffsiteTransportsPerDay,
calculatePatientLosBoxPlotData,

} from "../../../utils/postfestivalDashboard";
import { fetchPatientEncountersData } from "../../../utils/postfestivalDashboard";
Expand All @@ -32,10 +33,10 @@ import {
PostFestivalSummaryComponent,
PatientEncountersDashboardComponent,
OffsiteTransportsDashboardComponent,
PatientLengthOfStayDashboardComponent
LengthOfStayDashboardComponent
} from "../../../components/dashboard/PostFestivalDashboards";
import { SelectYearPrompt } from "../../../components/dashboard/PostFestivalDashboards";
import { AcuityCountPerDay, OffsiteTransportCountTotals, OffsiteTransportEntry } from "../../../interfaces/PosteventDashboard";
import { AcuityCountPerDay, LengthOfStayDashboardData, OffsiteTransportCountTotals, OffsiteTransportEntry } from "../../../interfaces/PosteventDashboard";



Expand Down Expand Up @@ -90,6 +91,13 @@ const MedicalPostEventSummaryDashboard = () => {
const [offsiteTransportCounts, setOffsiteTransportCounts] = useState<OffsiteTransportCountTotals | null>(null);
const [offsiteTransportEntries, setOffsiteTransportEntries] = useState<OffsiteTransportEntry[]>([]);
const [offsiteTransportsPerDayCount, setOffsiteTransportsPerDayCount] = useState<Record<string, Record<string, number>>>({});
const [losBoxPlotData, setLosBoxPlotData] = useState<LengthOfStayDashboardData>({
all: [],
red: [],
yellow: [],
green: [],
white: [],
});

// When the year is selected, fetch the patient encounters for that year
useEffect(() => {
Expand Down Expand Up @@ -154,6 +162,9 @@ const MedicalPostEventSummaryDashboard = () => {

const offsiteTransportsPerDayCount = calculateOffsiteTransportsPerDay(offsiteTransportList);
setOffsiteTransportsPerDayCount(offsiteTransportsPerDayCount);

const losBoxPlotData = calculatePatientLosBoxPlotData(patientEncounters);
setLosBoxPlotData(losBoxPlotData);
}
}
, [patientEncounters]);
Expand Down Expand Up @@ -192,8 +203,8 @@ const MedicalPostEventSummaryDashboard = () => {
<PatientEncountersDashboardComponent selectedYear={selectedYear} acuityCountPerDay={acuityCountPerDay} />
) : selectedView === "Offsite Transports" ? (
<OffsiteTransportsDashboardComponent offsiteTransportCounts={offsiteTransportCounts} offsiteTransportEntries={offsiteTransportEntries} offsiteTransportsPerDayCount={offsiteTransportsPerDayCount} />
) : selectedView === "Patient Length of Stay Times" ? (
<PatientLengthOfStayDashboardComponent />
) : selectedView === "Length of Stay" ? (
<LengthOfStayDashboardComponent losBoxPlotData={losBoxPlotData} />
) : <SelectYearPrompt />}
</FormProvider>
</Container>
Expand All @@ -205,13 +216,13 @@ const MedicalPostEventSummaryDashboard = () => {

export default ProtectedRoute(MedicalPostEventSummaryDashboard);

type SelectedView = "Summary" | "Patient Encounters" | "Offsite Transports" | "Patient Length of Stay Times" | "";
type SelectedView = "Summary" | "Patient Encounters" | "Offsite Transports" | "Length of Stay" | "";

const viewToNavigationText: { [key in SelectedView]: string } = {
"Summary": "Dashboard > Post-Event > Summary",
"Patient Encounters": "Dashboard > Post-Event > Patient Encounters",
"Offsite Transports": "Dashboard > Post-Event > Offsite Transports",
"Patient Length of Stay Times": "Dashboard > Post-Event > Patient Length of Stay Times",
"Length of Stay": "Dashboard > Post-Event > Length of Stay",
"": "Dashboard > Post-Event",
};

Expand Down
Loading

0 comments on commit 22784ba

Please sign in to comment.