From b0a3007d17dcfef73835bdde3ae17bc6d000498d Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Sun, 18 Jan 2026 10:55:14 +0100 Subject: [PATCH 01/23] Hide tools page from navigation --- app/src/App.jsx | 4 + app/src/components/layout/MainNavigation.jsx | 2 +- .../reporting/ReportingDelayPage.jsx | 536 ++++++++++++++++++ app/src/components/tools/ToolsPage.jsx | 54 ++ 4 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 app/src/components/reporting/ReportingDelayPage.jsx create mode 100644 app/src/components/tools/ToolsPage.jsx diff --git a/app/src/App.jsx b/app/src/App.jsx index 56af991..ab7f13d 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -10,6 +10,8 @@ import MyRespiLensDashboard from './components/myrespi/MyRespiLensDashboard'; import TournamentDashboard from './components/tournament/TournamentDashboard'; import UnifiedAppShell from './components/layout/UnifiedAppShell'; import Documentation from './components/Documentation' +import ReportingDelayPage from './components/reporting/ReportingDelayPage'; +import ToolsPage from './components/tools/ToolsPage'; import { Center, Text } from '@mantine/core'; // import ShutdownBanner from './components/ShutdownBanner';, no longer necessary @@ -42,6 +44,8 @@ const AppLayout = () => { } /> } /> } /> + } /> + } /> } /> diff --git a/app/src/components/layout/MainNavigation.jsx b/app/src/components/layout/MainNavigation.jsx index 8b6f1e6..183a7a8 100644 --- a/app/src/components/layout/MainNavigation.jsx +++ b/app/src/components/layout/MainNavigation.jsx @@ -50,4 +50,4 @@ const MainNavigation = () => { ); }; -export default MainNavigation; \ No newline at end of file +export default MainNavigation; diff --git a/app/src/components/reporting/ReportingDelayPage.jsx b/app/src/components/reporting/ReportingDelayPage.jsx new file mode 100644 index 0000000..5863712 --- /dev/null +++ b/app/src/components/reporting/ReportingDelayPage.jsx @@ -0,0 +1,536 @@ +import { useMemo, useRef, useState } from 'react'; +import { + Anchor, + Badge, + Box, + Button, + Card, + Container, + Divider, + Group, + List, + Paper, + ScrollArea, + Select, + SimpleGrid, + Slider, + Stack, + Table, + Text, + ThemeIcon, + Title, +} from '@mantine/core'; +import { IconArrowRight, IconClock, IconDownload, IconFileUpload } from '@tabler/icons-react'; +import { + BarElement, + CategoryScale, + Chart as ChartJS, + Legend, + LineElement, + LinearScale, + PointElement, + Tooltip, +} from 'chart.js'; +import { Chart } from 'react-chartjs-2'; + +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend); + +const SAMPLE_CSV = `reference_date,report_date,value +2024-02-01,2024-02-02,31 +2024-02-01,2024-02-03,56 +2024-02-01,2024-02-04,71 +2024-02-01,2024-02-05,79 +2024-02-02,2024-02-03,28 +2024-02-02,2024-02-04,52 +2024-02-02,2024-02-05,68 +2024-02-02,2024-02-06,74 +2024-02-03,2024-02-04,26 +2024-02-03,2024-02-05,45 +2024-02-03,2024-02-06,62 +2024-02-03,2024-02-07,70 +2024-02-04,2024-02-05,24 +2024-02-04,2024-02-06,39 +2024-02-04,2024-02-07,55 +2024-02-04,2024-02-08,63 +2024-02-05,2024-02-06,23 +2024-02-05,2024-02-07,35 +2024-02-05,2024-02-08,49 +2024-02-05,2024-02-09,58`; + +const parseCsv = (text) => { + const rows = text.trim().split(/\r?\n/).filter(Boolean); + if (rows.length < 2) { + throw new Error('CSV appears to be empty.'); + } + + const headers = rows[0].split(',').map((header) => header.trim().toLowerCase()); + const referenceIndex = headers.indexOf('reference_date'); + const reportIndex = headers.indexOf('report_date'); + const valueIndex = headers.indexOf('value'); + + if (referenceIndex < 0 || reportIndex < 0 || valueIndex < 0) { + throw new Error('CSV must include reference_date, report_date, and value columns.'); + } + + return rows.slice(1).map((row, index) => { + const parts = row.split(','); + const referenceDate = parts[referenceIndex]?.trim(); + const reportDate = parts[reportIndex]?.trim(); + const rawValue = parts[valueIndex]?.trim(); + const value = Number(rawValue); + + if (!referenceDate || !reportDate || Number.isNaN(value)) { + throw new Error(`Invalid row ${index + 2}.`); + } + + return { referenceDate, reportDate, value }; + }); +}; + +const formatDateLabel = (value) => new Date(value).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + +const buildTriangle = (records, { referenceDates, maxReportDate } = {}) => { + const allReferenceDates = Array.from(new Set(records.map((record) => record.referenceDate))).sort(); + const allReportDates = Array.from(new Set(records.map((record) => record.reportDate))).sort(); + const activeReferenceDates = referenceDates?.length ? referenceDates : allReferenceDates; + const activeReportDates = maxReportDate + ? allReportDates.filter((date) => date <= maxReportDate) + : allReportDates; + const allowedReferenceDates = new Set(activeReferenceDates); + const allowedReportDates = new Set(activeReportDates); + const filteredRecords = records.filter( + (record) => allowedReferenceDates.has(record.referenceDate) && allowedReportDates.has(record.reportDate), + ); + const valueMap = new Map(filteredRecords.map((record) => [`${record.referenceDate}|${record.reportDate}`, record.value])); + + return { referenceDates: activeReferenceDates, reportDates: activeReportDates, valueMap, filteredRecords }; +}; + +const buildDelayDistribution = (records) => { + const delayMap = new Map(); + records.forEach((record) => { + const delayDays = Math.max( + 0, + Math.round((new Date(record.reportDate) - new Date(record.referenceDate)) / (1000 * 60 * 60 * 24)), + ); + delayMap.set(delayDays, (delayMap.get(delayDays) || 0) + record.value); + }); + + const delays = Array.from(delayMap.keys()).sort((a, b) => a - b); + const values = delays.map((delay) => delayMap.get(delay) || 0); + return { delays, values }; +}; + +const calculateQuantiles = (delays, values) => { + const total = values.reduce((sum, value) => sum + value, 0); + if (total === 0) { + return { medianDelay: 0, delay95: 0, total: 0 }; + } + + let cumulative = 0; + let medianDelay = delays[0]; + let delay95 = delays[0]; + + delays.forEach((delay, index) => { + cumulative += values[index]; + const proportion = cumulative / total; + if (proportion >= 0.5 && medianDelay === delays[0]) { + medianDelay = delay; + } + if (proportion >= 0.95 && delay95 === delays[0]) { + delay95 = delay; + } + }); + + return { medianDelay, delay95, total }; +}; + +const ReportingDelayPage = () => { + const inputRef = useRef(null); + const [records, setRecords] = useState(() => parseCsv(SAMPLE_CSV)); + const [fileName, setFileName] = useState('sample-epinowcast.csv'); + const [error, setError] = useState(null); + const [isDragging, setIsDragging] = useState(false); + + const allReferenceDates = useMemo( + () => Array.from(new Set(records.map((record) => record.referenceDate))).sort(), + [records], + ); + const allReportDates = useMemo( + () => Array.from(new Set(records.map((record) => record.reportDate))).sort(), + [records], + ); + const [referenceRange, setReferenceRange] = useState([0, Math.max(0, allReferenceDates.length - 1)]); + const [maxReportDate, setMaxReportDate] = useState(allReportDates.at(-1) ?? null); + + const activeReferenceDates = useMemo(() => { + const [start, end] = referenceRange; + return allReferenceDates.slice(start, end + 1); + }, [allReferenceDates, referenceRange]); + + const triangle = useMemo( + () => + buildTriangle(records, { + referenceDates: activeReferenceDates, + maxReportDate, + }), + [records, activeReferenceDates, maxReportDate], + ); + const distribution = useMemo( + () => buildDelayDistribution(triangle.filteredRecords), + [triangle.filteredRecords], + ); + const summary = useMemo( + () => calculateQuantiles(distribution.delays, distribution.values), + [distribution.delays, distribution.values], + ); + + const hasLongDelay = summary.delay95 >= 7; + const hasShortDelay = summary.delay95 <= 3; + + const recommendation = hasLongDelay + ? 'Nowcasting is recommended to account for substantial reporting delays.' + : hasShortDelay + ? 'Nowcasting may be optional; delays resolve quickly.' + : 'Consider nowcasting when you need near-real-time situational awareness.'; + + const handleFile = async (file) => { + if (!file) return; + try { + const text = await file.text(); + const parsed = parseCsv(text); + setRecords(parsed); + setFileName(file.name); + setError(null); + const parsedReferenceDates = Array.from(new Set(parsed.map((record) => record.referenceDate))).sort(); + const parsedReportDates = Array.from(new Set(parsed.map((record) => record.reportDate))).sort(); + setReferenceRange([0, Math.max(0, parsedReferenceDates.length - 1)]); + setMaxReportDate(parsedReportDates.at(-1) ?? null); + } catch (err) { + setError(err.message); + } + }; + + const sampleDataUri = useMemo(() => { + const encoded = encodeURIComponent(SAMPLE_CSV); + return `data:text/csv;charset=utf-8,${encoded}`; + }, []); + + const reportDateOptions = useMemo( + () => allReportDates.map((date) => ({ value: date, label: formatDateLabel(date) })), + [allReportDates], + ); + + const sliderMarks = useMemo(() => { + if (allReferenceDates.length <= 1) { + return [{ value: 0, label: allReferenceDates[0] ? formatDateLabel(allReferenceDates[0]) : '' }]; + } + return [ + { value: 0, label: formatDateLabel(allReferenceDates[0]) }, + { + value: allReferenceDates.length - 1, + label: formatDateLabel(allReferenceDates[allReferenceDates.length - 1]), + }, + ]; + }, [allReferenceDates]); + + const triangleHeader = [ + Reference date, + ...triangle.reportDates.map((date) => ( + + {formatDateLabel(date)} + + )), + ]; + + const triangleRows = triangle.referenceDates.map((referenceDate) => ( + + {formatDateLabel(referenceDate)} + {triangle.reportDates.map((reportDate) => { + const value = triangle.valueMap.get(`${referenceDate}|${reportDate}`); + const intensity = value ? Math.min(1, value / 80) : 0; + return ( + + {value ?? '—'} + + ); + })} + + )); + + const revisionChartData = useMemo(() => { + const referenceSeries = triangle.referenceDates.slice(0, 3); + const labels = triangle.reportDates.map(formatDateLabel); + return { + labels, + datasets: referenceSeries.map((referenceDate, index) => ({ + label: formatDateLabel(referenceDate), + data: triangle.reportDates.map((reportDate) => triangle.valueMap.get(`${referenceDate}|${reportDate}`) ?? null), + borderColor: ['#1c7ed6', '#2f9e44', '#f59f00'][index % 3], + backgroundColor: 'transparent', + tension: 0.3, + })), + }; + }, [triangle.referenceDates, triangle.reportDates, triangle.valueMap]); + + const delayChartData = useMemo(() => { + return { + labels: distribution.delays.map((delay) => `${delay}d`), + datasets: [ + { + label: 'Total reports', + data: distribution.values, + backgroundColor: '#4dabf7', + }, + ], + }; + }, [distribution.delays, distribution.values]); + + const chartOptions = { + responsive: true, + plugins: { + legend: { position: 'bottom' }, + tooltip: { mode: 'index', intersect: false }, + }, + scales: { + y: { beginAtZero: true }, + }, + }; + + return ( + + + + } w="fit-content"> + Reporting delay explorer + + Do I need to nowcast? + + Drop an EpiNowcast-style CSV to build a reporting triangle, delay distribution, and a quick recommendation. + Everything runs locally in your browser. + + + + { + event.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={(event) => { + event.preventDefault(); + setIsDragging(false); + handleFile(event.dataTransfer.files?.[0]); + }} + style={{ + borderStyle: 'dashed', + borderColor: isDragging ? 'var(--mantine-color-blue-6)' : undefined, + background: isDragging ? 'var(--mantine-color-blue-0)' : undefined, + }} + > + + + + + Drag & drop a CSV file here + + Expected columns: reference_date, report_date, value. + + + Each row should be a cumulative total for one reference date as reported on a later report date. + + + + EpiNowcast sample datasets + + + Baseline nowcast demo data + + + + + + + + Current dataset: {fileName} + + {error && ( + + {error} Showing the last valid dataset. + + )} + handleFile(event.target.files?.[0])} + /> + + + + + + + + Reporting triangle + {triangle.referenceDates.length} reference dates + + + + Use the slider to focus on a subset of reference dates, and set a report-date cutoff for longer + series. + + formatDateLabel(allReferenceDates[value])} + minRange={0} + range + /> + { + + + + Column mapping + {csvHeaders.length} columns detected + + + Map your CSV columns to the required fields. Defaults are auto-detected when possible. + + + setColumnMapping((prev) => ({ ...prev, reportDate: value ?? '' }))} + size="sm" + /> + setColumnFilters((prev) => ({ ...prev, [column]: value }))} + clearable + searchable + size="sm" + /> + ))} + + + + )} + From a8bdff349164300bf3e0366aac61a4f7f2dbc877 Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Mon, 19 Jan 2026 18:18:37 +0100 Subject: [PATCH 03/23] Rename reporting triangle route and fix slider --- app/src/App.jsx | 2 +- app/src/components/reporting/ReportingDelayPage.jsx | 8 +++----- app/src/components/tools/ToolsPage.jsx | 6 +++--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/src/App.jsx b/app/src/App.jsx index ab7f13d..25ceafe 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -45,7 +45,7 @@ const AppLayout = () => { } /> } /> } /> - } /> + } /> } /> diff --git a/app/src/components/reporting/ReportingDelayPage.jsx b/app/src/components/reporting/ReportingDelayPage.jsx index fc485f4..2f26e86 100644 --- a/app/src/components/reporting/ReportingDelayPage.jsx +++ b/app/src/components/reporting/ReportingDelayPage.jsx @@ -10,10 +10,10 @@ import { Group, List, Paper, + RangeSlider, ScrollArea, Select, SimpleGrid, - Slider, Stack, Table, Text, @@ -392,7 +392,7 @@ const ReportingDelayPage = () => { } w="fit-content"> - Reporting delay explorer + Reporting triangle explorer Do I need to nowcast? @@ -557,7 +557,7 @@ const ReportingDelayPage = () => { Use the slider to focus on a subset of reference dates, and set a report-date cutoff for longer series. - { step={1} marks={sliderMarks} label={(value) => formatDateLabel(allReferenceDates[value])} - minRange={0} - range /> { Each row is a reference date, each column is a report date. Darker cells are larger cumulative counts. + {!showTriangleNumbers && ' Values are hidden for dense tables; hover to inspect.'} - - - - {triangleHeader} - - {triangleRows} -
-
+ + + + + {triangleHeader} + + {triangleRows} +
+
+
From ca2f2de7d533c4941a31fdd60e2be9120d539038 Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Mon, 19 Jan 2026 23:37:18 +0100 Subject: [PATCH 08/23] Refine reporting triangle flow and cutoff --- .../reporting/ReportingDelayPage.jsx | 170 ++++++++++++------ 1 file changed, 118 insertions(+), 52 deletions(-) diff --git a/app/src/components/reporting/ReportingDelayPage.jsx b/app/src/components/reporting/ReportingDelayPage.jsx index b61cbc1..b570c4f 100644 --- a/app/src/components/reporting/ReportingDelayPage.jsx +++ b/app/src/components/reporting/ReportingDelayPage.jsx @@ -10,12 +10,15 @@ import { Divider, Group, List, + Modal, + NumberInput, Paper, RangeSlider, ScrollArea, Select, SimpleGrid, Stack, + Stepper, Table, Text, ThemeIcon, @@ -99,21 +102,53 @@ const getDefaultReferenceRange = (dates) => { return [resolvedStart, dates.length - 1]; }; -const buildTriangle = (records, { referenceDates, maxReportDate } = {}) => { +const getFrequencyUnit = (dates) => { + if (dates.length < 2) { + return { unit: 'day', unitDays: 1 }; + } + const diffs = dates + .slice(1) + .map((date, index) => new Date(date) - new Date(dates[index])) + .map((diff) => Math.max(1, Math.round(diff / (1000 * 60 * 60 * 24)))) + .sort((a, b) => a - b); + const median = diffs[Math.floor(diffs.length / 2)]; + if (median >= 28) { + return { unit: 'month', unitDays: 30 }; + } + if (median >= 6) { + return { unit: 'week', unitDays: 7 }; + } + return { unit: 'day', unitDays: 1 }; +}; + +const buildTriangle = (records, { referenceDates, maxLagDays } = {}) => { const allReferenceDates = Array.from(new Set(records.map((record) => record.referenceDate))).sort(); const allReportDates = Array.from(new Set(records.map((record) => record.reportDate))).sort(); const activeReferenceDates = referenceDates?.length ? referenceDates : allReferenceDates; - const activeReportDates = maxReportDate - ? allReportDates.filter((date) => date <= maxReportDate) + const activeReportDates = maxLagDays + ? allReportDates.filter((date) => { + return activeReferenceDates.some((referenceDate) => { + const diff = Math.round((new Date(date) - new Date(referenceDate)) / (1000 * 60 * 60 * 24)); + return diff >= 0 && diff <= maxLagDays; + }); + }) : allReportDates; const allowedReferenceDates = new Set(activeReferenceDates); const allowedReportDates = new Set(activeReportDates); const filteredRecords = records.filter( (record) => allowedReferenceDates.has(record.referenceDate) && allowedReportDates.has(record.reportDate), ); - const valueMap = new Map(filteredRecords.map((record) => [`${record.referenceDate}|${record.reportDate}`, record.value])); + const lagFilteredRecords = maxLagDays + ? filteredRecords.filter((record) => { + const delay = Math.round( + (new Date(record.reportDate) - new Date(record.referenceDate)) / (1000 * 60 * 60 * 24), + ); + return delay >= 0 && delay <= maxLagDays; + }) + : filteredRecords; + const valueMap = new Map(lagFilteredRecords.map((record) => [`${record.referenceDate}|${record.reportDate}`, record.value])); - return { referenceDates: activeReferenceDates, reportDates: activeReportDates, valueMap, filteredRecords }; + return { referenceDates: activeReferenceDates, reportDates: activeReportDates, valueMap, filteredRecords: lagFilteredRecords }; }; const buildDelayDistribution = (records) => { @@ -242,7 +277,16 @@ const ReportingDelayPage = () => { [records], ); const [referenceRange, setReferenceRange] = useState(() => getDefaultReferenceRange(allReferenceDates)); - const [maxReportDate, setMaxReportDate] = useState(allReportDates.at(-1) ?? null); + const { unit, unitDays } = useMemo(() => getFrequencyUnit(allReferenceDates), [allReferenceDates]); + const maxLagDays = useMemo(() => { + if (!records.length) return 0; + return Math.max( + ...records.map((record) => + Math.round((new Date(record.reportDate) - new Date(record.referenceDate)) / (1000 * 60 * 60 * 24)), + ), + ); + }, [records]); + const [maxLagUnits, setMaxLagUnits] = useState(() => Math.max(0, Math.ceil(maxLagDays / unitDays))); const activeReferenceDates = useMemo(() => { const [start, end] = referenceRange; @@ -253,9 +297,9 @@ const ReportingDelayPage = () => { () => buildTriangle(records, { referenceDates: activeReferenceDates, - maxReportDate, + maxLagDays: maxLagUnits * unitDays, }), - [records, activeReferenceDates, maxReportDate], + [records, activeReferenceDates, maxLagUnits, unitDays], ); const distribution = useMemo( () => buildDelayDistribution(triangle.filteredRecords), @@ -295,9 +339,7 @@ const ReportingDelayPage = () => { setColumnMapping(nextMapping); setColumnFilters({}); const parsedReferenceDates = Array.from(new Set(buildRecordsFromMapping(parsed.records, nextMapping).map((record) => record.referenceDate))).sort(); - const parsedReportDates = Array.from(new Set(buildRecordsFromMapping(parsed.records, nextMapping).map((record) => record.reportDate))).sort(); setReferenceRange(getDefaultReferenceRange(parsedReferenceDates)); - setMaxReportDate(parsedReportDates.at(-1) ?? null); } catch (err) { setError(err.message); } @@ -329,23 +371,11 @@ const ReportingDelayPage = () => { } return [nextStart, nextEnd]; }); - if (maxReportDate && !allReportDates.includes(maxReportDate)) { - setMaxReportDate(allReportDates.at(-1) ?? null); - } - }, [allReferenceDates, allReportDates, maxReportDate]); + }, [allReferenceDates]); useEffect(() => { - const handleFullscreenChange = () => { - setIsTriangleFullscreen(document.fullscreenElement === triangleRef.current); - }; - document.addEventListener('fullscreenchange', handleFullscreenChange); - return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); - }, []); - - const reportDateOptions = useMemo( - () => allReportDates.map((date) => ({ value: date, label: formatDateLabel(date) })), - [allReportDates], - ); + setMaxLagUnits(Math.max(0, Math.ceil(maxLagDays / unitDays))); + }, [maxLagDays, unitDays]); const sliderMarks = useMemo(() => { if (allReferenceDates.length <= 1) { @@ -360,23 +390,21 @@ const ReportingDelayPage = () => { ]; }, [allReferenceDates]); - const triangleHeader = [ - Reference date, - ...triangle.reportDates.map((date) => ( - - {formatDateLabel(date)} - - )), - ]; - const showTriangleNumbers = triangle.referenceDates.length <= 16 && triangle.reportDates.length <= 16; + const maxDisplayRows = 80; + const maxDisplayCols = 80; + const displayReferenceDates = triangle.referenceDates.slice(-maxDisplayRows); + const displayReportDates = triangle.reportDates.slice(-maxDisplayCols); + const isTriangleTruncated = + displayReferenceDates.length < triangle.referenceDates.length || + displayReportDates.length < triangle.reportDates.length; const activeRangeLabel = activeReferenceDates.length ? `${formatDateLabel(activeReferenceDates[0])}–${formatDateLabel(activeReferenceDates.at(-1))}` : 'No data selected'; - const triangleRows = triangle.referenceDates.map((referenceDate) => ( + const triangleRows = displayReferenceDates.map((referenceDate) => ( {formatDateLabel(referenceDate)} - {triangle.reportDates.map((reportDate) => { + {displayReportDates.map((reportDate) => { const value = triangle.valueMap.get(`${referenceDate}|${reportDate}`); const intensity = value ? Math.min(1, value / 80) : 0; return ( @@ -525,6 +553,12 @@ const ReportingDelayPage = () => {
+ + + + + + @@ -532,7 +566,7 @@ const ReportingDelayPage = () => { {csvHeaders.length} columns detected - Map your CSV columns to the required fields. Defaults are auto-detected when possible. + Map your CSV columns to the required fields. We auto-detect when possible, but please confirm each field. setMaxLagUnits(Number(value) || 0)} + min={0} + max={Math.max(0, Math.ceil(maxLagDays / unitDays))} + clampBehavior="strict" size="sm" /> Each row is a reference date, each column is a report date. Darker cells are larger cumulative counts. {!showTriangleNumbers && ' Values are hidden for dense tables; hover to inspect.'} + {isTriangleTruncated && ' Showing a recent subset to keep the table responsive.'} - {triangleHeader} + + Reference date + {displayReportDates.map((date) => ( + + {formatDateLabel(date)} + + ))} + {triangleRows}
@@ -754,6 +790,36 @@ const ReportingDelayPage = () => {
+ setIsTriangleFullscreen(false)} + fullScreen + title="Reporting triangle" + padding="md" + > + + + + Showing {activeRangeLabel} · Cutoff {maxLagUnits} {unit}(s) + + + + + + + Reference date + {displayReportDates.map((date) => ( + + {formatDateLabel(date)} + + ))} + + + {triangleRows} +
+
+
+
); }; From 6d095a5cb7bbed477577ba1eb5b9affac0b0ebdf Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Tue, 20 Jan 2026 00:01:18 +0100 Subject: [PATCH 09/23] Reorder reporting triangle layout --- .../reporting/ReportingDelayPage.jsx | 433 +++++++++--------- 1 file changed, 223 insertions(+), 210 deletions(-) diff --git a/app/src/components/reporting/ReportingDelayPage.jsx b/app/src/components/reporting/ReportingDelayPage.jsx index b570c4f..c2136c5 100644 --- a/app/src/components/reporting/ReportingDelayPage.jsx +++ b/app/src/components/reporting/ReportingDelayPage.jsx @@ -18,7 +18,6 @@ import { Select, SimpleGrid, Stack, - Stepper, Table, Text, ThemeIcon, @@ -476,126 +475,122 @@ const ReportingDelayPage = () => { - { - event.preventDefault(); - setIsDragging(true); - }} - onDragLeave={() => setIsDragging(false)} - onDrop={(event) => { - event.preventDefault(); - setIsDragging(false); - handleFile(event.dataTransfer.files?.[0]); - }} - style={{ - borderStyle: 'dashed', - borderColor: isDragging ? 'var(--mantine-color-blue-6)' : undefined, - background: isDragging ? 'var(--mantine-color-blue-0)' : undefined, - }} - > - - - - - Drag & drop a CSV file here - - Expected columns: reference_date, report_date, value. - - - Each row should be a cumulative total for one reference date as reported on a later report date. - - - Optional columns like location, age, or target are supported and can be filtered after upload. - - - - EpiNowcast sample datasets - - - Baseline nowcast demo data - - - - - - - - Current dataset: {fileName} - - {error && ( - - {error} Showing the last valid dataset. + + { + event.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={(event) => { + event.preventDefault(); + setIsDragging(false); + handleFile(event.dataTransfer.files?.[0]); + }} + style={{ + borderStyle: 'dashed', + borderColor: isDragging ? 'var(--mantine-color-blue-6)' : undefined, + background: isDragging ? 'var(--mantine-color-blue-0)' : undefined, + }} + > + + + + + Drag & drop a CSV file here + + Expected columns: reference_date, report_date, value. - )} - {!mappingComplete && ( - - We couldn't automatically map the required columns. Please select them below. + + Each row should be a cumulative total for one reference date as reported on a later report date. - )} - handleFile(event.target.files?.[0])} - /> - - - - - - - - - - - - - Column mapping - {csvHeaders.length} columns detected - - - Map your CSV columns to the required fields. We auto-detect when possible, but please confirm each field. - - - setColumnMapping((prev) => ({ ...prev, reportDate: value ?? '' }))} - size="sm" - /> - handleFile(event.target.files?.[0])} /> - - - + + + + + + + Column mapping + {csvHeaders.length} columns detected + + + Map your CSV columns to the required fields. We auto-detect when possible, but please confirm each field. + + + setColumnMapping((prev) => ({ ...prev, reportDate: value ?? '' }))} + size="sm" + /> + { )} - - - - - Reporting triangle - - {triangle.referenceDates.length} reference dates - setIsTriangleFullscreen((prev) => !prev)} - > - {isTriangleFullscreen ? : } - - - + + + + Window & cutoff + {triangle.referenceDates.length} reference dates + + + Use the slider to focus on a subset of reference dates (rows), and set how far after reference dates to + include reports (columns). + + - - Use the slider to focus on a subset of reference dates, and set a report-date cutoff for longer - series. + + Reference-date window { Showing {activeRangeLabel} - setMaxLagUnits(Number(value) || 0)} - min={0} - max={Math.max(0, Math.ceil(maxLagDays / unitDays))} - clampBehavior="strict" - size="sm" - /> - - Each row is a reference date, each column is a report date. Darker cells are larger cumulative counts. - {!showTriangleNumbers && ' Values are hidden for dense tables; hover to inspect.'} - {isTriangleTruncated && ' Showing a recent subset to keep the table responsive.'} - - - - - - - Reference date - {displayReportDates.map((date) => ( - - {formatDateLabel(date)} - - ))} - - - {triangleRows} -
-
-
-
-
+ setMaxLagUnits(Number(value) || 0)} + min={0} + max={Math.max(0, Math.ceil(maxLagDays / unitDays))} + clampBehavior="strict" + size="sm" + /> +
+ + + @@ -706,9 +675,7 @@ const ReportingDelayPage = () => { - - @@ -721,6 +688,66 @@ const ReportingDelayPage = () => { + + + + + + Reporting triangle + + {triangle.referenceDates.length} reference dates + setIsTriangleFullscreen((prev) => !prev)} + > + {isTriangleFullscreen ? : } + + + + + + Rows = reference date, columns = report date. + + {showTriangleNumbers ? 'Values shown' : 'Heatmap mode'} + + + Darker cells are larger cumulative counts. + {!showTriangleNumbers && ' Values are hidden for dense tables; hover to inspect.'} + {isTriangleTruncated && ' Showing a recent subset to keep the table responsive.'} + + + + + + + Reference date + {displayReportDates.map((date) => ( + + {formatDateLabel(date)} + + ))} + + + {triangleRows} +
+
+
+
+
+ + + + + Caveats from reporting triangles + + Late reports can be structurally different (e.g., lab corrections, backfills). + Holiday effects and reporting interruptions bias delay estimates. + Negative revisions need separate handling before computing cumulative totals. + Changes in case definitions or data pipelines break comparability over time. + + + @@ -752,43 +779,29 @@ const ReportingDelayPage = () => { - - - - Caveats from reporting triangles - - Late reports can be structurally different (e.g., lab corrections, backfills). - Holiday effects and reporting interruptions bias delay estimates. - Negative revisions need separate handling before computing cumulative totals. - Changes in case definitions or data pipelines break comparability over time. - - - - - - - Useful links - - - - Baseline nowcast toolkit - - - - - EpiNowcast documentation - - - - - - Want help operationalizing? Start with the baseline nowcast decision tree and upgrade to EpiNowcast - when you need probabilistic delay distributions. - - - - - + + + Useful links + + + + Baseline nowcast toolkit + + + + + EpiNowcast documentation + + + + + + Want help operationalizing? Start with the baseline nowcast decision tree and upgrade to EpiNowcast + when you need probabilistic delay distributions. + + + + Date: Tue, 20 Jan 2026 09:57:02 +0100 Subject: [PATCH 10/23] Add tour and heatmap toggle --- .../reporting/ReportingDelayPage.jsx | 236 +++++++++++++----- 1 file changed, 179 insertions(+), 57 deletions(-) diff --git a/app/src/components/reporting/ReportingDelayPage.jsx b/app/src/components/reporting/ReportingDelayPage.jsx index c2136c5..0be3b1b 100644 --- a/app/src/components/reporting/ReportingDelayPage.jsx +++ b/app/src/components/reporting/ReportingDelayPage.jsx @@ -18,6 +18,7 @@ import { Select, SimpleGrid, Stack, + Switch, Table, Text, ThemeIcon, @@ -42,6 +43,7 @@ import { Tooltip, } from 'chart.js'; import { Chart } from 'react-chartjs-2'; +import Plot from 'react-plotly.js'; ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend); @@ -93,12 +95,7 @@ const formatDateLabel = (value) => new Date(value).toLocaleDateString('en-US', { const getDefaultReferenceRange = (dates) => { if (!dates.length) return [0, 0]; - const lastDate = new Date(dates[dates.length - 1]); - const cutoffDate = new Date(lastDate); - cutoffDate.setMonth(cutoffDate.getMonth() - 4); - const startIndex = dates.findIndex((date) => new Date(date) >= cutoffDate); - const resolvedStart = startIndex >= 0 ? startIndex : 0; - return [resolvedStart, dates.length - 1]; + return [0, dates.length - 1]; }; const getFrequencyUnit = (dates) => { @@ -120,6 +117,34 @@ const getFrequencyUnit = (dates) => { return { unit: 'day', unitDays: 1 }; }; +const loadDriver = () => { + if (window.driver) { + return Promise.resolve(window.driver); + } + + const existingScript = document.querySelector('script[data-driverjs]'); + if (existingScript) { + return new Promise((resolve) => { + existingScript.addEventListener('load', () => resolve(window.driver)); + }); + } + + return new Promise((resolve, reject) => { + const cssLink = document.createElement('link'); + cssLink.rel = 'stylesheet'; + cssLink.href = 'https://cdn.jsdelivr.net/npm/driver.js@1.3.0/dist/driver.min.css'; + document.head.appendChild(cssLink); + + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/driver.js@1.3.0/dist/driver.min.js'; + script.async = true; + script.dataset.driverjs = 'true'; + script.onload = () => resolve(window.driver); + script.onerror = reject; + document.body.appendChild(script); + }); +}; + const buildTriangle = (records, { referenceDates, maxLagDays } = {}) => { const allReferenceDates = Array.from(new Set(records.map((record) => record.referenceDate))).sort(); const allReportDates = Array.from(new Set(records.map((record) => record.reportDate))).sort(); @@ -219,7 +244,6 @@ const INITIAL_MAPPING = { const ReportingDelayPage = () => { const inputRef = useRef(null); - const triangleRef = useRef(null); const [csvRows, setCsvRows] = useState(() => INITIAL_PARSED.records); const [csvHeaders, setCsvHeaders] = useState(() => INITIAL_PARSED.headers); const [fileName, setFileName] = useState('sample-epinowcast.csv'); @@ -228,6 +252,7 @@ const ReportingDelayPage = () => { const [columnMapping, setColumnMapping] = useState(INITIAL_MAPPING); const [columnFilters, setColumnFilters] = useState({}); const [isTriangleFullscreen, setIsTriangleFullscreen] = useState(false); + const [showHeatmap, setShowHeatmap] = useState(false); const headerOptions = useMemo( () => csvHeaders.map((header) => ({ value: header, label: header })), @@ -349,16 +374,38 @@ const ReportingDelayPage = () => { return `data:text/csv;charset=utf-8,${encoded}`; }, []); - useEffect(() => { - const referenceIndex = INITIAL_PARSED.normalizedHeaders.indexOf('reference_date'); - const reportIndex = INITIAL_PARSED.normalizedHeaders.indexOf('report_date'); - const valueIndex = INITIAL_PARSED.normalizedHeaders.indexOf('value'); - setColumnMapping({ - referenceDate: referenceIndex >= 0 ? INITIAL_PARSED.headers[referenceIndex] : '', - reportDate: reportIndex >= 0 ? INITIAL_PARSED.headers[reportIndex] : '', - value: valueIndex >= 0 ? INITIAL_PARSED.headers[valueIndex] : '', - }); - }, []); + const startTour = async () => { + try { + const driver = await loadDriver(); + if (!driver) { + return; + } + const tour = driver({ + showProgress: true, + steps: [ + { + element: '#reporting-triangle-upload', + popover: { title: 'Import CSV', description: 'Drop your file or download the sample CSV to start.' }, + }, + { + element: '#reporting-triangle-mapping', + popover: { title: 'Map columns', description: 'Confirm which columns hold reference dates and reports.' }, + }, + { + element: '#reporting-triangle-trajectory', + popover: { title: 'Trajectories', description: 'Watch how reports revise over time.' }, + }, + { + element: '#reporting-triangle-distribution', + popover: { title: 'Delay distribution', description: 'Inspect how long delays typically are.' }, + }, + ], + }); + tour.drive(); + } catch (error) { + // Optional: ignore if driver.js cannot be loaded in the environment. + } + }; useEffect(() => { const maxIndex = Math.max(0, allReferenceDates.length - 1); @@ -389,7 +436,6 @@ const ReportingDelayPage = () => { ]; }, [allReferenceDates]); - const showTriangleNumbers = triangle.referenceDates.length <= 16 && triangle.reportDates.length <= 16; const maxDisplayRows = 80; const maxDisplayCols = 80; const displayReferenceDates = triangle.referenceDates.slice(-maxDisplayRows); @@ -415,13 +461,38 @@ const ReportingDelayPage = () => { borderRadius: 6, }} > - {showTriangleNumbers ? value ?? '—' : ''} + {value ?? '—'} ); })} )); + const heatmapData = useMemo(() => { + return [ + { + z: displayReferenceDates.map((referenceDate) => + displayReportDates.map((reportDate) => triangle.valueMap.get(`${referenceDate}|${reportDate}`) ?? null), + ), + x: displayReportDates.map(formatDateLabel), + y: displayReferenceDates.map(formatDateLabel), + type: 'heatmap', + colorscale: 'Blues', + showscale: false, + hovertemplate: 'Reference %{y}
Report %{x}
Value %{z}', + }, + ]; + }, [displayReferenceDates, displayReportDates, triangle.valueMap]); + + const heatmapLayout = useMemo(() => { + return { + margin: { l: 80, r: 20, t: 20, b: 60 }, + xaxis: { title: 'Report date' }, + yaxis: { title: 'Reference date' }, + height: 520, + }; + }, []); + const revisionChartData = useMemo(() => { const referenceSeries = triangle.referenceDates.slice(0, 3); const labels = triangle.reportDates.map(formatDateLabel); @@ -475,11 +546,26 @@ const ReportingDelayPage = () => { + + + Introduction + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip + ex ea commodo consequat. + + + + + { event.preventDefault(); setIsDragging(true); @@ -553,7 +639,7 @@ const ReportingDelayPage = () => { - + Column mapping @@ -565,7 +651,7 @@ const ReportingDelayPage = () => { setColumnMapping((prev) => ({ ...prev, reportDate: value ?? '' }))} @@ -581,7 +667,7 @@ const ReportingDelayPage = () => { /> handleFile(event.target.files?.[0])} + /> + + + + + + + Column mapping + {csvHeaders.length} columns detected + + + Map your CSV columns to the required fields. We auto-detect when possible, but please confirm each field. + + + setColumnMapping((prev) => ({ ...prev, reportDate: value ?? '' }))} + size="sm" + /> + setColumnFilters((prev) => ({ ...prev, [column]: value }))} + clearable + searchable + size="sm" + /> + ))} + + + + )} + + + + + + + {analysisStarted && ( + <> + + + + + + Revision trajectories + first 3 reference dates + + + View how reported totals evolve over successive reports. + + + + + + + + + + + Delay distribution + {summary.total} total reports + + + How long it takes for reports to arrive after the reference date. + + + + + + + + + + + Reporting triangle + + {triangle.referenceDates.length} reference dates + setIsTriangleFullscreen((prev) => !prev)} + > + {isTriangleFullscreen ? : } + + + + + + + Rows = reference date, columns = report date. + + + Diagonal cells (delay = 0) represent reports received on the same day as the reference date. + + + setShowHeatmap(event.currentTarget.checked)} + size="sm" + /> + + + Darker cells are larger cumulative counts. + {isTriangleTruncated && ' Showing a recent subset to keep the table responsive.'} + + {showHeatmap ? ( + + ) : ( + + + + + Reference date + {displayReportDates.map((date) => ( + + {formatDateLabel(date)} + + ))} + + + {triangleRows} +
+
+ )} +
+
+ + + + + Caveats from reporting triangles + + Late reports can be structurally different (e.g., lab corrections, backfills). + Holiday effects and reporting interruptions bias delay estimates. + Negative revisions need separate handling before computing cumulative totals. + Changes in case definitions or data pipelines break comparability over time. + + + + + + + Summary + + + Based on this dataset: + + The reported quantity is 95% complete after {summary.delay95} days, with a + median delay of {summary.medianDelay} days. + + + {recommendation} + + + + + + + + Decision tree + }> + Long delays (≥7 days): prioritize nowcasting and backfill-aware evaluation. + Moderate delays (4–6 days): nowcasting for rapid reporting, forecasts for planning. + Short delays (≤3 days): focus on forecasts, optional nowcast for same-week metrics. + + + + + + + + + Useful links + + + + Baseline nowcast toolkit + + + + + EpiNowcast documentation + + + + + + Want help operationalizing? Start with the baseline nowcast decision tree and upgrade to EpiNowcast + when you need probabilistic delay distributions. + + + + + + )} + + setIsTriangleFullscreen(false)} + fullScreen + title="Reporting triangle" + padding="md" + > + + + + Showing {activeRangeLabel} · Cutoff {maxLagUnits} {unit}(s) + + setShowHeatmap(event.currentTarget.checked)} + size="sm" + /> + + {showHeatmap ? ( + + ) : ( + + + + + Reference date + {displayReportDates.map((date) => ( + + {formatDateLabel(date)} + + ))} + + + {triangleRows} +
+
+ )} +
+
+ + ); +}; + +export default ReportingDelayPage; diff --git a/app/src/components/tools/ToolsPage.jsx b/app/src/components/tools/ToolsPage.jsx new file mode 100644 index 0000000..19e8109 --- /dev/null +++ b/app/src/components/tools/ToolsPage.jsx @@ -0,0 +1,54 @@ +import { Badge, Button, Card, Container, Group, SimpleGrid, Stack, Text, Title } from '@mantine/core'; +import { IconClock, IconTools } from '@tabler/icons-react'; +import { Link } from 'react-router-dom'; + +const tools = [ + { + title: 'Reporting triangle explorer', + description: + 'Upload an EpiNowcast-style CSV to build reporting triangles, delay distributions, and a nowcast recommendation.', + icon: IconClock, + href: '/reporting-triangle', + badge: 'Nowcasting', + }, +]; + +const ToolsPage = () => { + return ( + + + + + Tools + + + Browse lightweight utilities for data QA, reporting triangles, and operational decisions. + + + + {tools.map((tool) => ( + + + + + + {tool.title} + + {tool.badge} + + + {tool.description} + + + + + ))} + + + + ); +}; + +export default ToolsPage; From a53a0404d7956ade7fab8caa75032195a9b234ce Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Tue, 20 Jan 2026 11:03:33 +0100 Subject: [PATCH 17/23] lint --- app/src/components/reporting/ReportingDelayPage.jsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/components/reporting/ReportingDelayPage.jsx b/app/src/components/reporting/ReportingDelayPage.jsx index 009ac6f..1709f92 100644 --- a/app/src/components/reporting/ReportingDelayPage.jsx +++ b/app/src/components/reporting/ReportingDelayPage.jsx @@ -274,10 +274,6 @@ const ReportingDelayPage = () => { () => Array.from(new Set(records.map((record) => record.referenceDate))).sort(), [records], ); - const allReportDates = useMemo( - () => Array.from(new Set(records.map((record) => record.reportDate))).sort(), - [records], - ); const [referenceRange, setReferenceRange] = useState(() => getDefaultReferenceRange(allReferenceDates)); const { unit, unitDays } = useMemo(() => getFrequencyUnit(allReferenceDates), [allReferenceDates]); const maxLagDays = useMemo(() => { From ad8721cb36848a7455f98d2fed9697f9f975f250 Mon Sep 17 00:00:00 2001 From: Joseph Lemaitre Date: Tue, 20 Jan 2026 11:18:47 +0100 Subject: [PATCH 18/23] better page --- app/src/App.jsx | 2 +- .../reporting/ReportingDelayPage.jsx | 30 +++++++++---------- app/src/components/tools/ToolsPage.jsx | 8 ++--- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/app/src/App.jsx b/app/src/App.jsx index 25ceafe..87ef0dd 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -44,7 +44,7 @@ const AppLayout = () => { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/app/src/components/reporting/ReportingDelayPage.jsx b/app/src/components/reporting/ReportingDelayPage.jsx index 1709f92..eb51097 100644 --- a/app/src/components/reporting/ReportingDelayPage.jsx +++ b/app/src/components/reporting/ReportingDelayPage.jsx @@ -550,10 +550,9 @@ const ReportingDelayPage = () => { } w="fit-content"> Reporting triangle explorer - Do I need to nowcast? + Do you need to nowcast? What is your reporting delay distribution? - Drop an EpiNowcast-style CSV to build a reporting triangle, delay distribution, and a quick recommendation. - Everything runs locally in your browser. + Analyze your reporting delay distribution with RespiLens and epinowcast. Everything runs locally in your browser. @@ -561,16 +560,17 @@ const ReportingDelayPage = () => { Introduction - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et - dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip - ex ea commodo consequat. + Do you need nowcasting ? What does your reporting delay distrubution look like ? Let's dive into that using this little app. Nothing leave your computer (say how to check). + So upload a data with some columns indicating the refrence date of an event, the report date when it was reported, and the value reported. Optionally you can have other columns like location, age group, or target type to filter the data. + You'll see your reporting distrubtion and the so called reporting triangle introduced by (probably Kaitlyn Johnson et al. but really i need to check this). This + For any deeper dive open the link below to epinowcast on which this work is based. - - EpiNowcast sample datasets + + EpiNowcast - Baseline nowcast demo data + Baselinenowcast - - - - - Window & cutoff - {triangle.referenceDates.length} reference dates - - - Use the slider to focus on a subset of reference dates (rows), and set how far after reference dates to - include reports (columns). - - - - - Reference-date window - - formatDateLabel(allReferenceDates[value])} - /> - - Showing {activeRangeLabel} - - - - setMaxLagUnits(Number(value) || 0)} - min={0} - max={Math.max(0, Math.ceil(maxLagDays / unitDays))} - clampBehavior="strict" - size="sm" - /> - - Latest observed delay: {maxLagDays} days (~{Math.ceil(maxLagDays / unitDays)} {unit}s) - - - - - - - {showFilters && ( + {analysisStarted && ( + <> - Filter optional columns - Filters update the triangle + Window & cutoff + {triangle.referenceDates.length} reference dates - Narrow by location, age, target, or other metadata. Clear a filter to include all values. + Use the slider to focus on a subset of reference dates (rows), and set how far after reference dates to + include reports (columns). - {extraColumnOptions.map(({ column, options }) => ( -