diff --git a/src/soa_builder/web/app.py b/src/soa_builder/web/app.py
index 57eb17d..dc3685f 100644
--- a/src/soa_builder/web/app.py
+++ b/src/soa_builder/web/app.py
@@ -96,6 +96,10 @@
load_epoch_type_map,
table_has_columns as _table_has_columns,
iso_duration_to_days,
+ get_encounter_id,
+ get_epoch_uid,
+ get_schedule_timeline,
+ get_scheduled_activity_instance,
)
# Audit functions
@@ -3855,6 +3859,12 @@ def ui_edit(request: Request, soa_id: int):
if not default_timeline and "unassigned" in instances_by_timeline:
default_timeline = "unassigned"
+ instances_crud = instances_router.list_instances(soa_id)
+ encounter_options = get_encounter_id(soa_id)
+ epoch_options = get_epoch_uid(soa_id)
+ schedule_timelines_options = get_schedule_timeline(soa_id)
+ instance_options = get_scheduled_activity_instance(soa_id)
+
return templates.TemplateResponse(
request,
"edit.html",
@@ -3862,6 +3872,11 @@ def ui_edit(request: Request, soa_id: int):
"soa_id": soa_id,
"epochs": epochs,
"instances": instances,
+ "instances_crud": instances_crud,
+ "encounter_options": encounter_options,
+ "epoch_options": epoch_options,
+ "schedule_timelines_options": schedule_timelines_options,
+ "instance_options": instance_options,
"activities": activities_page,
"elements": elements,
"arms": arms_enriched,
diff --git a/src/soa_builder/web/routers/instances.py b/src/soa_builder/web/routers/instances.py
index 97742b4..1cef88b 100644
--- a/src/soa_builder/web/routers/instances.py
+++ b/src/soa_builder/web/routers/instances.py
@@ -15,6 +15,7 @@
get_epoch_uid,
get_schedule_timeline,
get_scheduled_activity_instance,
+ redirect_url_from_referer as _redirect_url,
)
router = APIRouter()
@@ -216,7 +217,9 @@ def ui_create_instance(
member_of_timeline=member_of_timeline,
)
create_instance(soa_id, payload)
- return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/instances", status_code=303)
+ return RedirectResponse(
+ url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/instances"), status_code=303
+ )
# API endpoint to update a timeline instance in an SOA
@@ -388,7 +391,9 @@ def ui_update_instance(
member_of_timeline=member_of_timeline,
)
update_instance(soa_id, instance_id, payload)
- return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/instances", status_code=303)
+ return RedirectResponse(
+ url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/instances"), status_code=303
+ )
# API endpoint to delete a timeline instance
@@ -438,7 +443,9 @@ def delete_instance(soa_id: int, instance_id: int):
@router.post("/ui/soa/{soa_id}/instances/{instance_id}/delete")
def ui_del_instance(request: Request, soa_id: int, instance_id: int):
delete_instance(soa_id, instance_id)
- return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/instances", status_code=303)
+ return RedirectResponse(
+ url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/instances"), status_code=303
+ )
# API endpoint to reorder instances
diff --git a/src/soa_builder/web/routers/schedule_timelines.py b/src/soa_builder/web/routers/schedule_timelines.py
index d46830a..47d2e9c 100644
--- a/src/soa_builder/web/routers/schedule_timelines.py
+++ b/src/soa_builder/web/routers/schedule_timelines.py
@@ -9,7 +9,17 @@
from ..audit import _record_schedule_timeline_audit
from ..db import _connect
from ..schemas import ScheduleTimelineUpdate, ScheduleTimelineCreate
-from ..utils import soa_exists, get_scheduled_activity_instance
+from ..utils import (
+ soa_exists,
+ get_scheduled_activity_instance,
+ get_schedule_timeline,
+ get_encounter_id,
+ get_epoch_uid,
+ get_study_timing_type,
+ redirect_url_from_referer as _redirect_url,
+)
+from .instances import list_instances
+from .timings import list_timings
router = APIRouter()
@@ -121,6 +131,87 @@ def ui_list_schedule_timelines(request: Request, soa_id: int):
)
+# UI combined study timing page (schedule timelines + instances + timings)
+@router.get("/ui/soa/{soa_id}/study_timing", response_class=HTMLResponse)
+def ui_study_timing(request: Request, soa_id: int):
+ if not soa_exists(soa_id):
+ raise HTTPException(404, "SOA not found")
+
+ # Schedule timelines data
+ schedule_timelines = list_schedule_timelines(soa_id)
+ instance_options = get_scheduled_activity_instance(soa_id)
+
+ # Instances data
+ instances = list_instances(soa_id)
+ encounter_options = get_encounter_id(soa_id)
+ epoch_options = get_epoch_uid(soa_id)
+ schedule_timelines_options = get_schedule_timeline(soa_id)
+
+ # Timings data (with code_uid -> submission_value decoding)
+ timings = list_timings(soa_id)
+ try:
+ sv_to_code = get_study_timing_type("C201264")
+ except Exception:
+ sv_to_code = {}
+ try:
+ sv_to_code_rtf = get_study_timing_type("C201265")
+ except Exception:
+ sv_to_code_rtf = {}
+ code_to_sv = {v: k for k, v in (sv_to_code or {}).items()}
+ code_to_sv_rtf = {v: k for k, v in (sv_to_code_rtf or {}).items()}
+ conn = _connect()
+ cur = conn.cursor()
+ cur.execute("SELECT code_uid, code FROM code WHERE soa_id=?", (soa_id,))
+ code_uid_to_code = {r[0]: r[1] for r in cur.fetchall() if r[0]}
+ cur.execute(
+ "SELECT study_id, study_label, study_description, name, created_at FROM soa WHERE id=?",
+ (soa_id,),
+ )
+ meta_row = cur.fetchone()
+ conn.close()
+ for t in timings:
+ sv = None
+ cu = t.get("type")
+ if cu and cu in code_uid_to_code:
+ sv = code_to_sv.get(str(code_uid_to_code.get(cu)))
+ t["type_submission_value"] = sv
+ rtf_sv = None
+ rtf_cu = t.get("relative_to_from")
+ if rtf_cu and rtf_cu in code_uid_to_code:
+ rtf_sv = code_to_sv_rtf.get(str(code_uid_to_code.get(rtf_cu)))
+ t["relative_to_from_submission_value"] = rtf_sv
+ timing_type_options = sorted(sv_to_code.keys()) if sv_to_code else []
+ relative_to_from_options = sorted(sv_to_code_rtf.keys()) if sv_to_code_rtf else []
+
+ study_id, study_label, study_description, study_name, study_created_at = meta_row
+ study_meta = {
+ "study_id": study_id,
+ "study_label": study_label,
+ "study_description": study_description,
+ "study_name": study_name,
+ "study_created_at": study_created_at,
+ }
+
+ return templates.TemplateResponse(
+ request,
+ "study_timing.html",
+ {
+ "request": request,
+ "soa_id": soa_id,
+ "schedule_timelines": schedule_timelines,
+ "instances": instances,
+ "timings": timings,
+ "instance_options": instance_options,
+ "encounter_options": encounter_options,
+ "epoch_options": epoch_options,
+ "schedule_timelines_options": schedule_timelines_options,
+ "timing_type_options": timing_type_options,
+ "relative_to_from_options": relative_to_from_options,
+ **study_meta,
+ },
+ )
+
+
# API endpoint for creating a schedule timeline in an SOA
@router.post(
"/soa/{soa_id}/schedule_timelines",
@@ -229,7 +320,8 @@ def ui_create_schedule_timeline(
)
create_schedule_timeline(soa_id, payload)
return RedirectResponse(
- url=f"/ui/soa/{int(soa_id)}/schedule_timelines", status_code=303
+ url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/schedule_timelines"),
+ status_code=303,
)
@@ -387,7 +479,8 @@ def ui_update_schedule_timeline(
)
update_schedule_timeline(soa_id, schedule_timeline_id, payload)
return RedirectResponse(
- url=f"/ui/soa/{int(soa_id)}/schedule_timelines", status_code=303
+ url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/schedule_timelines"),
+ status_code=303,
)
@@ -451,5 +544,6 @@ def ui_delete_schedule_timeline(
):
delete_schedule_timeline(soa_id, schedule_timeline_id)
return RedirectResponse(
- url=f"/ui/soa/{int(soa_id)}/schedule_timelines", status_code=303
+ url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/schedule_timelines"),
+ status_code=303,
)
diff --git a/src/soa_builder/web/routers/timings.py b/src/soa_builder/web/routers/timings.py
index f2474fc..0aa6040 100644
--- a/src/soa_builder/web/routers/timings.py
+++ b/src/soa_builder/web/routers/timings.py
@@ -18,6 +18,7 @@
get_schedule_timeline,
get_study_timing_type,
get_next_code_uid as _get_next_code_uid,
+ redirect_url_from_referer as _redirect_url,
)
router = APIRouter()
@@ -332,7 +333,9 @@ def ui_create_timing(
msgs = "; ".join(e["msg"] for e in exc.errors())
raise HTTPException(400, f"Validation error: {msgs}")
create_timing(soa_id, payload)
- return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/timings", status_code=303)
+ return RedirectResponse(
+ url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/timings"), status_code=303
+ )
@router.get("/soa/{soa_id}/timing_audit", response_class=JSONResponse)
@@ -668,7 +671,9 @@ def ui_update_timing(
msgs = "; ".join(e["msg"] for e in exc.errors())
raise HTTPException(400, f"Validation error: {msgs}")
update_timing(soa_id, timing_id, payload)
- return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/timings", status_code=303)
+ return RedirectResponse(
+ url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/timings"), status_code=303
+ )
# API endpoint to delete a timing
@@ -719,4 +724,6 @@ def delete_timing(soa_id: int, timing_id: int):
@router.post("/ui/soa/{soa_id}/timings/{timing_id}/delete")
def ui_delete_timing(request: Request, soa_id: int, timing_id: int):
delete_timing(soa_id, timing_id)
- return RedirectResponse(url=f"/ui/soa/{int(soa_id)}/timings", status_code=303)
+ return RedirectResponse(
+ url=_redirect_url(request, f"/ui/soa/{int(soa_id)}/timings"), status_code=303
+ )
diff --git a/src/soa_builder/web/templates/_instances_section.html b/src/soa_builder/web/templates/_instances_section.html
new file mode 100644
index 0000000..38d7c29
--- /dev/null
+++ b/src/soa_builder/web/templates/_instances_section.html
@@ -0,0 +1,219 @@
+
Scheduled Activity Instances for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}
+
+{% if not hide_return_link %}
+
+{% endif %}
+
+
+
+
+
+ | id |
+ Order |
+ Name |
+ Label |
+ Description |
+ Next Instance (default condition) |
+ Epoch |
+ Timeline ID |
+ Timeline Exit ID |
+ Encounter |
+ Member of Timeline |
+ Save |
+ Delete |
+
+
+ |
+
+ {% for i in instances or [] %}
+
+
+ |
+
+ |
+
+
+
+ |
+
+ {% else %}
+ | No instances yet. |
+ {% endfor %}
+
+
+
diff --git a/src/soa_builder/web/templates/_schedule_timelines_section.html b/src/soa_builder/web/templates/_schedule_timelines_section.html
new file mode 100644
index 0000000..0e3ece4
--- /dev/null
+++ b/src/soa_builder/web/templates/_schedule_timelines_section.html
@@ -0,0 +1,107 @@
+Schedule Timelines for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}
+
+{% if not hide_return_link %}
+
+{% endif %}
+
+
+
+
+
+
+ | id |
+ Name |
+ Label |
+ Description |
+ Main Timeline |
+ Entry Condition |
+ Entry ID |
+ Exit ID |
+ Save |
+ Delete |
+
+ {% for st in schedule_timelines %}
+
+
+ |
+
+ |
+
+ {% else %}
+
+ | No schedule timelines yet. |
+
+ {% endfor %}
+
diff --git a/src/soa_builder/web/templates/_timings_section.html b/src/soa_builder/web/templates/_timings_section.html
new file mode 100644
index 0000000..c6d55cb
--- /dev/null
+++ b/src/soa_builder/web/templates/_timings_section.html
@@ -0,0 +1,208 @@
+Timings for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}
+
+{% if not hide_return_link %}
+
+{% endif %}
+
+
+
+
+
+ | id |
+ Name |
+ Label |
+ Description |
+ Rel From Instance |
+ Value |
+ Value Label |
+ Type |
+ Rel To Instance |
+ Window Lower |
+ Window Upper |
+ Window Label |
+ Member of Timeline |
+ Rel To/From |
+ Save |
+ Delete |
+
+ {% for t in timings %}
+
+
+ |
+
+ |
+
+ {% else %}
+ | No timings yet. |
+ {% endfor %}
+
+
+
diff --git a/src/soa_builder/web/templates/base.html b/src/soa_builder/web/templates/base.html
index b29210a..cd95642 100644
--- a/src/soa_builder/web/templates/base.html
+++ b/src/soa_builder/web/templates/base.html
@@ -11,9 +11,7 @@
Home
{% if soa_id %}
Activities
- Schedule Timelines
- Study Timing
- Scheduled Activity Instances
+ Study Timing
Epochs
Arms
Study Cells
diff --git a/src/soa_builder/web/templates/edit.html b/src/soa_builder/web/templates/edit.html
index 30bc2f0..2e51fe9 100644
--- a/src/soa_builder/web/templates/edit.html
+++ b/src/soa_builder/web/templates/edit.html
@@ -82,7 +82,14 @@ Editing SoA for {% if study_label %}{{ study_label }}{% else %}{{ study_id }
-
+
+ {% with instances=instances_crud, study_name=study_name, hide_return_link=true %}
+
+ Scheduled Activity Instances ({{ instances_crud|length }})
+ {% include '_instances_section.html' %}
+
+ {% endwith %}
+
Activities ({{ activities|length }}) (drag to reorder)
@@ -370,6 +377,7 @@ Matrix: {% if timeline_instances and timeline_instances[0].timeline_name %}{
const saved = localStorage.getItem('soa_collapse_'+sectionId);
if(saved === '0') el.open = false;
}
+persistSection('edit-instances-section');
persistSection('activities-section');
persistSection('study-cells-section');
persistSection('transition-rules-section');
diff --git a/src/soa_builder/web/templates/instances.html b/src/soa_builder/web/templates/instances.html
index c167d46..be4d9a5 100644
--- a/src/soa_builder/web/templates/instances.html
+++ b/src/soa_builder/web/templates/instances.html
@@ -1,222 +1,4 @@
{% extends 'base.html' %}
{% block content %}
-
-Scheduled Activity Instances for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}
-
-
-
-
-
-
-
- | id |
- Order |
- Name |
- Label |
- Description |
- Next Instance (default condition) |
- Epoch |
- Timeline ID |
- Timeline Exit ID |
- Encounter |
- Member of Timeline |
- Save |
- Delete |
-
-
- |
-
- {% for i in instances or [] %}
-
-
- |
-
- |
-
-
-
- |
-
- {% else %}
- | No instances yet. |
- {% endfor %}
-
-
-
-
-{% endblock %}
\ No newline at end of file
+{% include '_instances_section.html' %}
+{% endblock %}
diff --git a/src/soa_builder/web/templates/schedule_timelines.html b/src/soa_builder/web/templates/schedule_timelines.html
index 3bc65b4..d06aabf 100644
--- a/src/soa_builder/web/templates/schedule_timelines.html
+++ b/src/soa_builder/web/templates/schedule_timelines.html
@@ -1,110 +1,4 @@
{% extends 'base.html' %}
{% block content %}
-Schedule Timelines for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}
-
-
-
-
-
-
-
-
- | id |
- Name |
- Label |
- Description |
- Main Timeline |
- Entry Condition |
- Entry ID |
- Exit ID |
- Save |
- Delete |
-
- {% for st in schedule_timelines %}
-
-
- |
-
- |
-
- {% else %}
-
- | No instances yet. |
-
- {% endfor %}
-
-
-
-{% endblock %}
\ No newline at end of file
+{% include '_schedule_timelines_section.html' %}
+{% endblock %}
diff --git a/src/soa_builder/web/templates/study_timing.html b/src/soa_builder/web/templates/study_timing.html
new file mode 100644
index 0000000..b66d156
--- /dev/null
+++ b/src/soa_builder/web/templates/study_timing.html
@@ -0,0 +1,48 @@
+{% extends 'base.html' %}
+{% block content %}
+Study Timing for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}
+
+
+
+{% set hide_return_link = true %}
+
+
+ Schedule Timelines
+ {% include '_schedule_timelines_section.html' %}
+
+
+
+ Scheduled Activity Instances
+ {% include '_instances_section.html' %}
+
+
+
+ Timings
+ {% include '_timings_section.html' %}
+
+
+
+
+
+{% endblock %}
diff --git a/src/soa_builder/web/templates/timings.html b/src/soa_builder/web/templates/timings.html
index 6e2813a..7923dcb 100644
--- a/src/soa_builder/web/templates/timings.html
+++ b/src/soa_builder/web/templates/timings.html
@@ -1,209 +1,4 @@
{% extends 'base.html' %}
{% block content %}
-Timings for Study: {% if study_label %}{{ study_label }}{% else %}{{ study_name }}{% endif %}
-
-
-
-
-
-
-
- | id |
- Name |
- Label |
- Description |
- Rel From Instance |
- Value |
- Value Label |
- Type |
- Rel To Instance |
- Window Lower |
- Window Upper |
- Window Label |
- Member of Timeline |
- Rel To/From |
- Save |
- Delete |
-
- {% for t in timings %}
-
-
- |
-
- |
-
- {% else %}
- | No timings yet. |
- {% endfor %}
-
-
-
+{% include '_timings_section.html' %}
{% endblock %}
diff --git a/src/soa_builder/web/utils.py b/src/soa_builder/web/utils.py
index b6fdf8b..2a60580 100644
--- a/src/soa_builder/web/utils.py
+++ b/src/soa_builder/web/utils.py
@@ -3,6 +3,8 @@
import re
import requests
import time
+from urllib.parse import urlparse, urlunparse
+from fastapi import Request
from .db import _connect
_epoch_type_cache: dict[str, Any] = {
@@ -57,6 +59,17 @@
"""
+def redirect_url_from_referer(request: Request, fallback: str) -> str:
+ """Return the Referer URL if it's a same-origin /ui/ path, else fallback."""
+ referer = request.headers.get("referer", "")
+ if referer:
+ parsed = urlparse(referer)
+ base = urlparse(str(request.base_url))
+ if parsed.netloc == base.netloc and parsed.path.startswith("/ui/"):
+ return urlunparse(("", "", parsed.path, "", parsed.query, parsed.fragment))
+ return fallback
+
+
def iso_duration_to_days(iso_duration: str) -> float:
"""
Convert an ISO-8601 duration (e.g. 'P1D', 'P2W', 'P1Y2M3D', 'P1DT12H')
diff --git a/tests/test_routers_study_timing.py b/tests/test_routers_study_timing.py
new file mode 100644
index 0000000..c12cca1
--- /dev/null
+++ b/tests/test_routers_study_timing.py
@@ -0,0 +1,61 @@
+"""Tests for the combined study timing UI route."""
+
+from fastapi.testclient import TestClient
+
+from soa_builder.web.app import app
+
+client = TestClient(app)
+
+
+def test_ui_study_timing_200():
+ """GET /ui/soa/{soa_id}/study_timing returns 200 HTML for a valid SoA."""
+ r = client.post("/soa", json={"name": "Study Timing 200 Test"})
+ soa_id = r.json()["id"]
+
+ resp = client.get(f"/ui/soa/{soa_id}/study_timing")
+ assert resp.status_code == 200
+ assert "text/html" in resp.headers.get("content-type", "")
+
+
+def test_ui_study_timing_contains_all_sections():
+ """Response HTML includes all three section containers."""
+ r = client.post("/soa", json={"name": "Study Timing Sections Test"})
+ soa_id = r.json()["id"]
+
+ resp = client.get(f"/ui/soa/{soa_id}/study_timing")
+ assert resp.status_code == 200
+ assert "study-timing-schedule-timelines" in resp.text
+ assert "study-timing-instances" in resp.text
+ assert "study-timing-timings" in resp.text
+
+
+def test_ui_study_timing_404_nonexistent_soa():
+ """GET /ui/soa/999999/study_timing returns 404 for nonexistent SoA."""
+ resp = client.get("/ui/soa/999999/study_timing")
+ assert resp.status_code == 404
+
+
+def test_ui_study_timing_with_data():
+ """Created entities appear in the rendered page."""
+ r = client.post("/soa", json={"name": "Study Timing Data Test"})
+ soa_id = r.json()["id"]
+
+ # Create one of each entity via API
+ client.post(
+ f"/soa/{soa_id}/schedule_timelines",
+ json={"name": "My Test Timeline"},
+ )
+ client.post(
+ f"/soa/{soa_id}/instances",
+ json={"name": "My Test Instance"},
+ )
+ client.post(
+ f"/soa/{soa_id}/timings",
+ json={"name": "My Test Timing"},
+ )
+
+ resp = client.get(f"/ui/soa/{soa_id}/study_timing")
+ assert resp.status_code == 200
+ assert "My Test Timeline" in resp.text
+ assert "My Test Instance" in resp.text
+ assert "My Test Timing" in resp.text