From f5218ef5d992abc9d1a312b2c8283d9e3df08265 Mon Sep 17 00:00:00 2001
From: Darren <3921919+pendingintent@users.noreply.github.com>
Date: Mon, 23 Feb 2026 10:18:19 -0500
Subject: [PATCH 1/7] Consolidated study timing pages into one to support
easier workflow
---
src/soa_builder/web/app.py | 15 ++
src/soa_builder/web/routers/instances.py | 25 +-
.../web/routers/schedule_timelines.py | 114 ++++++++-
src/soa_builder/web/routers/timings.py | 25 +-
.../web/templates/_instances_section.html | 219 +++++++++++++++++
.../_schedule_timelines_section.html | 107 +++++++++
.../web/templates/_timings_section.html | 208 ++++++++++++++++
src/soa_builder/web/templates/base.html | 4 +-
src/soa_builder/web/templates/edit.html | 10 +-
src/soa_builder/web/templates/instances.html | 222 +-----------------
.../web/templates/schedule_timelines.html | 110 +--------
.../web/templates/study_timing.html | 48 ++++
src/soa_builder/web/templates/timings.html | 207 +---------------
13 files changed, 766 insertions(+), 548 deletions(-)
create mode 100644 src/soa_builder/web/templates/_instances_section.html
create mode 100644 src/soa_builder/web/templates/_schedule_timelines_section.html
create mode 100644 src/soa_builder/web/templates/_timings_section.html
create mode 100644 src/soa_builder/web/templates/study_timing.html
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..e62a3e8 100644
--- a/src/soa_builder/web/routers/instances.py
+++ b/src/soa_builder/web/routers/instances.py
@@ -29,6 +29,19 @@ def _nz(s: Optional[str]) -> Optional[str]:
return s or None
+def _redirect_url(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:
+ from urllib.parse import urlparse
+
+ parsed = urlparse(referer)
+ base = urlparse(str(request.base_url))
+ if parsed.netloc == base.netloc and parsed.path.startswith("/ui/"):
+ return parsed.path
+ return fallback
+
+
# API endpoint to list timeline instances for SOA
@router.get("/soa/{soa_id}/instances", response_class=JSONResponse, response_model=None)
def list_instances(soa_id: int):
@@ -216,7 +229,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 +403,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 +455,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..0c6ae26 100644
--- a/src/soa_builder/web/routers/schedule_timelines.py
+++ b/src/soa_builder/web/routers/schedule_timelines.py
@@ -9,7 +9,16 @@
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,
+)
+from .instances import list_instances
+from .timings import list_timings
router = APIRouter()
@@ -24,6 +33,19 @@ def _nz(s: Optional[str]) -> Optional[str]:
return s or None
+def _redirect_url(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:
+ from urllib.parse import urlparse
+
+ parsed = urlparse(referer)
+ base = urlparse(str(request.base_url))
+ if parsed.netloc == base.netloc and parsed.path.startswith("/ui/"):
+ return parsed.path
+ return fallback
+
+
def _to_bool(v: Optional[str]) -> bool:
if v is None:
return False
@@ -121,6 +143,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 +332,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 +491,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 +556,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..6c5c820 100644
--- a/src/soa_builder/web/routers/timings.py
+++ b/src/soa_builder/web/routers/timings.py
@@ -32,6 +32,19 @@ def _nz(s: Optional[str]) -> Optional[str]:
return s or None
+def _redirect_url(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:
+ from urllib.parse import urlparse
+
+ parsed = urlparse(referer)
+ base = urlparse(str(request.base_url))
+ if parsed.netloc == base.netloc and parsed.path.startswith("/ui/"):
+ return parsed.path
+ return fallback
+
+
# API endpoint to list timings for SOA
@router.get("/soa/{soa_id}/timings", response_class=JSONResponse, response_model=None)
def list_timings(soa_id: int):
@@ -332,7 +345,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 +683,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 +736,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..5a9df5b
--- /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 instances 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..14fb31e 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=soa_name, hide_return_link=true %}
+
+ Scheduled Activity Instances ({{ instances_crud|length }})
+ {% include '_instances_section.html' %}
+
+ {% endwith %}
+
Activities ({{ activities|length }}) (drag to reorder)