diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index a07ea4f..24505a0 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -412,13 +412,13 @@ Drawer selection → update_drug_selection → app-state store → load_pathway_
- **Checkpoint**: Sankey tab renders real drug transition flows ✓
### 9.7 Dosing Interval Comparison chart (Tab 6)
-- [ ] Create `dash_app/callbacks/dosing.py`:
+- [x] Create `dash_app/callbacks/dosing.py`:
- Build horizontal grouped bar chart from `get_dosing_intervals()` data
- Uses `parse_average_spacing()` to extract weekly interval numbers
- Y-axis = trust or directorate, X-axis = weekly interval
-- [ ] Create figure function in `src/visualization/`
-- [ ] Wire into tab switching
-- **Checkpoint**: Dosing tab renders real data with parsed interval numbers
+- [x] Create figure function in `src/visualization/`
+- [x] Wire into tab switching
+- **Checkpoint**: Dosing tab renders real data with parsed interval numbers ✓
### 9.8 Directorate × Drug Heatmap chart (Tab 7)
- [ ] Create `dash_app/callbacks/heatmap.py`:
diff --git a/dash_app/callbacks/chart.py b/dash_app/callbacks/chart.py
index 1b56d59..e1ca566 100644
--- a/dash_app/callbacks/chart.py
+++ b/dash_app/callbacks/chart.py
@@ -183,6 +183,33 @@ def _render_sankey(app_state, title):
return create_sankey_figure(data, title)
+def _render_dosing(app_state, title):
+ """Build the dosing interval figure from current filter state."""
+ from dash_app.data.queries import get_dosing_intervals
+ from visualization.plotly_generator import create_dosing_figure
+
+ filter_id = (app_state or {}).get("date_filter_id", "all_6mo")
+ chart_type = (app_state or {}).get("chart_type", "directory")
+
+ selected_drugs = (app_state or {}).get("selected_drugs") or []
+ selected_trusts = (app_state or {}).get("selected_trusts") or []
+ drug = selected_drugs[0] if len(selected_drugs) == 1 else None
+ trust = selected_trusts[0] if len(selected_trusts) == 1 else None
+
+ try:
+ data = get_dosing_intervals(filter_id, chart_type, drug, trust)
+ except Exception:
+ log.exception("Failed to load dosing interval data")
+ return _empty_figure("Failed to load dosing interval data.")
+
+ if not data:
+ return _empty_figure("No dosing interval data available.\nTry adjusting your filters.")
+
+ # Single drug selected → show per-trust comparison; otherwise overview
+ group_by = "trust" if drug else "drug"
+ return create_dosing_figure(data, title, group_by)
+
+
def register_chart_callbacks(app):
"""Register tab switching, pathway data loading, and chart rendering callbacks."""
@@ -308,6 +335,9 @@ def register_chart_callbacks(app):
elif active_tab == "sankey":
fig = _render_sankey(app_state, title)
+ elif active_tab == "dosing":
+ fig = _render_dosing(app_state, title)
+
else:
# Placeholder for charts not yet implemented
tab_label = dict(TAB_DEFINITIONS).get(active_tab, active_tab)
diff --git a/progress.txt b/progress.txt
index 439a8f3..86754ad 100644
--- a/progress.txt
+++ b/progress.txt
@@ -1623,3 +1623,60 @@ Console error: `WARN: Multiple implied roots, cannot build icicle hierarchy of t
- Note: Sankey has ordinal suffixes (e.g., "ADALIMUMAB (1st)") to prevent self-loops
### Blocked items:
- None
+
+## Iteration 29 — 2026-02-06
+### Task: Phase 9 — Task 9.7 (Dosing Interval Comparison chart — Tab 6)
+### Why this task:
+- Tasks 9.1–9.6 complete. Task 9.7 is next in sequence.
+- Progress.txt from iteration 28 explicitly recommended this task.
+- Uses existing `get_dosing_intervals()` query and `parse_average_spacing()` parsing.
+### Status: COMPLETE
+### What was done:
+- **Created `create_dosing_figure(data, title, group_by)` in `src/visualization/plotly_generator.py`** (~180 lines):
+ - Two modes via `group_by` parameter:
+ - **"drug" (overview)**: Weighted average weekly interval per drug, one horizontal bar per drug sorted by patient count. Bars coloured by interval (NHS blue gradient — darker = more frequent dosing). Patient count annotations on right side.
+ - **"trust" (per-drug comparison)**: Grouped horizontal bars per trust, one trace per directory. Enables cross-trust dosing comparison for a specific drug.
+ - Hover shows: drug/trust, interval, avg doses, avg treatment weeks, patient count
+ - Dynamic height, NHS design aesthetic (Source Sans 3, transparent, clean grid)
+ - Helper functions: `_dosing_by_drug()` and `_dosing_by_trust()` for clean separation
+- **Added `_render_dosing(app_state, title)` helper in `dash_app/callbacks/chart.py`**:
+ - Extracts filter params (date_filter_id, chart_type, single drug/trust)
+ - Auto-selects `group_by`: "trust" when single drug selected, "drug" for overview
+ - Calls `get_dosing_intervals()` wrapper then `create_dosing_figure()`
+ - Handles empty data and exceptions gracefully
+- **Wired into `update_chart` dispatch**: `active_tab == "dosing"` → `_render_dosing()`
+### Validation results:
+- Tier 1 (Code): `from dash_app.app import app` — OK, 11 callbacks registered
+- Tier 1 (App starts): `python run_dash.py` → "Dash is running on http://127.0.0.1:8050/" — no errors
+- Tier 3 (Functional):
+ - Directory chart overview: 124 rows → 36 drugs, intervals 4.3–52.4 weeks — renders correctly
+ - Indication chart: 219 rows → 37 drugs — renders correctly
+ - ADALIMUMAB single drug: 14 rows → 4 directory traces, per-trust comparison — renders correctly
+ - NNUH trust filter: single trust reduces data — filters correctly
+ - Date filter (2yr_12mo): 89 rows → 29 drugs — filters correctly
+ - Empty data: returns empty figure — handled correctly
+ - Icicle still works: 293 nodes, 11,118 patients — no regression
+ - Market share, cost effectiveness, cost waterfall, Sankey — no regressions
+### Files changed:
+- `src/visualization/plotly_generator.py` — Added: `create_dosing_figure()`, `_dosing_by_drug()`, `_dosing_by_trust()` (~180 lines)
+- `dash_app/callbacks/chart.py` — Added: `_render_dosing()` helper + dispatch branch
+- `IMPLEMENTATION_PLAN.md` — Task 9.7 marked [x]
+### Committed: [pending]
+### Patterns discovered:
+- Dosing chart benefits from two distinct modes: overview (all drugs, weighted averages) and comparison (single drug, per-trust). The `group_by` parameter makes this clean without separate figure functions.
+- Trust names are very long (e.g., "NORFOLK AND NORWICH UNIVERSITY HOSPITALS NHS FOUNDATION TRUST") — stripping " NHS FOUNDATION TRUST" and " HOSPITALS" suffixes greatly improves y-axis readability.
+- Weighted average by patient count is the right aggregation for dosing intervals — it prevents small patient groups from skewing the displayed interval.
+- Bar colour gradient by interval value provides immediate visual cue: darker blue = more frequent dosing, lighter = less frequent.
+### Next iteration should:
+- Start Task 9.8 — Directorate × Drug Heatmap chart (Tab 7)
+- Sub-steps:
+ 1. Create figure function in `src/visualization/plotly_generator.py` — `create_heatmap_figure(data, title, metric)`
+ 2. Build Plotly heatmap from `get_drug_directory_matrix()` data
+ 3. Rows = directorates (sorted by total patients), Columns = drugs (sorted by frequency)
+ 4. Cell colour = patient count (default), with possible toggle for cost or cost_pp_pa
+ 5. Wire into `update_chart` via `_render_heatmap()` helper
+ 6. Responds to trust filter, date filter, chart type toggle
+- Read `get_drug_directory_matrix()` in pathway_queries.py for exact data shape
+- Data returns: `{directories: [...], drugs: [...], matrix: {dir: {drug: {patients, cost, cost_pp_pa}}}}`
+### Blocked items:
+- None
diff --git a/src/visualization/plotly_generator.py b/src/visualization/plotly_generator.py
index dee7d8d..ba05b5b 100644
--- a/src/visualization/plotly_generator.py
+++ b/src/visualization/plotly_generator.py
@@ -834,6 +834,237 @@ def create_sankey_figure(
return fig
+def create_dosing_figure(
+ data: list[dict],
+ title: str = "",
+ group_by: str = "drug",
+) -> go.Figure:
+ """Create dosing interval comparison chart.
+
+ Shows weekly dosing intervals as horizontal bars, grouped either by drug
+ (overview mode) or by trust (single-drug comparison mode).
+
+ Args:
+ data: List of dicts from get_dosing_intervals() with keys:
+ drug, trust_name, directory, weekly_interval, dose_count,
+ total_weeks, patients.
+ title: Chart title suffix (filter description).
+ group_by: "drug" for drug-level overview (default),
+ "trust" for per-trust comparison of a single drug.
+
+ Returns:
+ Plotly Figure with horizontal grouped bar chart.
+ """
+ if not data:
+ return go.Figure()
+
+ nhs_colours = [
+ "#005EB8", "#003087", "#41B6E6", "#0066CC", "#1E88E5",
+ "#4FC3F7", "#009639", "#ED8B00", "#768692", "#AE2573",
+ "#8A1538", "#330072", "#DA291C", "#00A499", "#425563",
+ ]
+
+ if group_by == "trust":
+ # Single-drug mode: compare trusts, group bars by directory
+ fig = _dosing_by_trust(data, nhs_colours)
+ chart_title = f"Dosing Intervals by Trust"
+ else:
+ # Overview mode: weighted average per drug
+ fig = _dosing_by_drug(data, nhs_colours)
+ chart_title = "Dosing Interval Overview"
+
+ if title:
+ chart_title = f"{chart_title} — {title}"
+
+ n_rows = len(fig.data[0].y) if fig.data else 10
+ fig.update_layout(
+ title=dict(
+ text=chart_title,
+ font=dict(
+ family="Source Sans 3, system-ui, sans-serif",
+ size=18,
+ color="#003087",
+ ),
+ x=0.5,
+ xanchor="center",
+ ),
+ xaxis=dict(
+ title="Weekly Interval (weeks between doses)",
+ titlefont=dict(size=13, color="#425563"),
+ gridcolor="rgba(66,85,99,0.1)",
+ zeroline=True,
+ zerolinecolor="rgba(66,85,99,0.2)",
+ ),
+ yaxis=dict(
+ automargin=True,
+ tickfont=dict(size=11),
+ ),
+ font=dict(
+ family="Source Sans 3, system-ui, sans-serif",
+ size=12,
+ ),
+ paper_bgcolor="rgba(0,0,0,0)",
+ plot_bgcolor="rgba(0,0,0,0)",
+ margin=dict(t=60, l=20, r=40, b=60),
+ height=max(450, n_rows * 40 + 150),
+ bargap=0.15,
+ bargroupgap=0.05,
+ showlegend=True,
+ legend=dict(
+ orientation="h",
+ yanchor="top",
+ y=-0.12,
+ xanchor="center",
+ x=0.5,
+ font=dict(size=11),
+ ),
+ )
+
+ return fig
+
+
+def _dosing_by_drug(data: list[dict], colours: list[str]) -> go.Figure:
+ """Build dosing overview: one row per drug, bars per trust, showing weekly_interval."""
+ # Aggregate: weighted average interval per drug, summing patients
+ drug_agg = {}
+ for d in data:
+ drug = d["drug"]
+ pts = d["patients"] or 0
+ if drug not in drug_agg:
+ drug_agg[drug] = {"weighted_sum": 0.0, "total_patients": 0,
+ "dose_count_ws": 0.0, "total_weeks_ws": 0.0}
+ drug_agg[drug]["weighted_sum"] += d["weekly_interval"] * pts
+ drug_agg[drug]["total_patients"] += pts
+ drug_agg[drug]["dose_count_ws"] += d["dose_count"] * pts
+ drug_agg[drug]["total_weeks_ws"] += d["total_weeks"] * pts
+
+ # Build sorted list (by total patients desc)
+ drugs_sorted = sorted(
+ drug_agg.items(),
+ key=lambda x: x[1]["total_patients"],
+ )
+
+ drug_names = [d[0] for d in drugs_sorted]
+ intervals = []
+ patients_list = []
+ hover_texts = []
+
+ for drug, agg in drugs_sorted:
+ tp = agg["total_patients"]
+ avg_interval = agg["weighted_sum"] / tp if tp > 0 else 0
+ avg_doses = agg["dose_count_ws"] / tp if tp > 0 else 0
+ avg_weeks = agg["total_weeks_ws"] / tp if tp > 0 else 0
+ intervals.append(round(avg_interval, 1))
+ patients_list.append(tp)
+ hover_texts.append(
+ f"{drug}
"
+ f"Avg interval: {avg_interval:.1f} weeks
"
+ f"Avg doses: {avg_doses:.1f}
"
+ f"Avg treatment: {avg_weeks:.0f} weeks
"
+ f"Patients: {tp:,}"
+ )
+
+ # Colour bars by interval: lower = more frequent dosing = NHS blue, higher = lighter
+ max_interval = max(intervals) if intervals else 1
+ bar_colours = []
+ for iv in intervals:
+ ratio = iv / max_interval if max_interval > 0 else 0
+ # Interpolate NHS blue (#005EB8) to light blue (#41B6E6)
+ r = int(0x00 + (0x41 - 0x00) * ratio)
+ g = int(0x5E + (0xB6 - 0x5E) * ratio)
+ b = int(0xB8 + (0xE6 - 0xB8) * ratio)
+ bar_colours.append(f"rgb({r},{g},{b})")
+
+ fig = go.Figure()
+ fig.add_trace(go.Bar(
+ y=drug_names,
+ x=intervals,
+ orientation="h",
+ marker=dict(color=bar_colours, line=dict(color="#FFFFFF", width=0.5)),
+ text=[f"{iv}w" for iv in intervals],
+ textposition="outside",
+ textfont=dict(size=10, color="#425563"),
+ customdata=list(zip(hover_texts, patients_list)),
+ hovertemplate="%{customdata[0]}",
+ name="Weighted Avg Interval",
+ showlegend=False,
+ ))
+
+ # Add patient count annotations on the right
+ for i, (drug, pts) in enumerate(zip(drug_names, patients_list)):
+ fig.add_annotation(
+ x=max(intervals) * 1.15 if intervals else 10,
+ y=drug,
+ text=f"n={pts:,}",
+ showarrow=False,
+ font=dict(size=9, color="#768692"),
+ xanchor="left",
+ )
+
+ return fig
+
+
+def _dosing_by_trust(data: list[dict], colours: list[str]) -> go.Figure:
+ """Build per-trust comparison: one row per trust, bars per directory, showing weekly_interval."""
+ from collections import defaultdict
+
+ # Group by trust × directory
+ trust_dir = defaultdict(list)
+ for d in data:
+ trust_dir[(d["trust_name"], d["directory"])].append(d)
+
+ # Get unique trusts and directories
+ trusts = sorted(set(d["trust_name"] for d in data))
+ directories = sorted(set(d["directory"] for d in data))
+
+ fig = go.Figure()
+
+ for i, directory in enumerate(directories):
+ y_labels = []
+ x_vals = []
+ hover_list = []
+
+ for trust in trusts:
+ entries = trust_dir.get((trust, directory))
+ if not entries:
+ continue
+ # Average if multiple entries per trust+directory (shouldn't happen at level 3)
+ avg_iv = sum(e["weekly_interval"] * (e["patients"] or 0) for e in entries)
+ total_pts = sum(e["patients"] or 0 for e in entries)
+ if total_pts == 0:
+ continue
+ avg_iv /= total_pts
+ avg_doses = sum(e["dose_count"] * (e["patients"] or 0) for e in entries) / total_pts
+ avg_weeks = sum(e["total_weeks"] * (e["patients"] or 0) for e in entries) / total_pts
+
+ # Shorten trust name for readability
+ short_trust = trust.replace(" NHS FOUNDATION TRUST", "").replace(" HOSPITALS", "")
+ y_labels.append(short_trust)
+ x_vals.append(round(avg_iv, 1))
+ hover_list.append(
+ f"{short_trust}
"
+ f"Directorate: {directory}
"
+ f"Interval: {avg_iv:.1f} weeks
"
+ f"Avg doses: {avg_doses:.1f}
"
+ f"Treatment: {avg_weeks:.0f} weeks
"
+ f"Patients: {total_pts:,}"
+ )
+
+ if y_labels:
+ fig.add_trace(go.Bar(
+ y=y_labels,
+ x=x_vals,
+ orientation="h",
+ name=directory,
+ marker=dict(color=colours[i % len(colours)]),
+ customdata=hover_list,
+ hovertemplate="%{customdata}",
+ ))
+
+ fig.update_layout(barmode="group")
+ return fig
+
+
def save_figure_html(
fig: go.Figure, save_dir: str, title: str, open_browser: bool = False
) -> str: