From 8fe08ac98893cfeffe783d1334dd89bc649b6a1d Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Sun, 6 Sep 2026 13:03:14 +0200 Subject: [PATCH 1/2] Rename report section Gantt Interactive to Gantt and add waterfall note The Gantt section now opens with the line 'Unoptimized waterfall. Parallel work not modelled here.' so readers do not mistake the schedule for an optimized plan. The note lives in the dhtmlx template body content, so it also appears in the standalone gantt.html. Adds a test for the dhtmlx export that checks the note sits at the top of the body content. Updates the docs mention of the section name. Co-Authored-By: Claude Fable 5.1 --- docs/index.md | 2 +- .../worker_plan_internal/plan/nodes/report.py | 2 +- .../export_gantt_dhtmlx_template.html | 1 + .../tests/test_export_gantt_dhtmlx.py | 39 +++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 worker_plan/worker_plan_internal/schedule/tests/test_export_gantt_dhtmlx.py diff --git a/docs/index.md b/docs/index.md index e0bc72c41..ac082c04b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,7 +41,7 @@ PlanExe generates a **single HTML report** (a self-contained artifact you can op Open the sample report and do this: 1. Read **Executive Summary** to see the top-level deliverables, budget, risks, and next steps. -2. Jump to **Gantt Interactive** to see how the goal gets broken down into many concrete tasks. +2. Jump to **Gantt** to see how the goal gets broken down into many concrete tasks. 3. Open **Premortem** to see what could go wrong and what to do about it. --- diff --git a/worker_plan/worker_plan_internal/plan/nodes/report.py b/worker_plan/worker_plan_internal/plan/nodes/report.py index 123c2e701..4eca7d73b 100644 --- a/worker_plan/worker_plan_internal/plan/nodes/report.py +++ b/worker_plan/worker_plan_internal/plan/nodes/report.py @@ -73,7 +73,7 @@ def run_inner(self): rg = ReportGenerator() rg.append_markdown('Executive Summary', self.input()['executive_summary']['markdown'].path) - rg.append_html('Gantt Interactive', self.input()['create_schedule']['dhtmlx_html'].path) + rg.append_html('Gantt', self.input()['create_schedule']['dhtmlx_html'].path) rg.append_markdown('Pitch', self.input()['pitch_markdown']['markdown'].path) rg.append_markdown('Project Plan', self.input()['project_plan']['markdown'].path) rg.append_markdown('Strategic Decisions', self.input()['strategic_decisions_markdown']['markdown'].path) diff --git a/worker_plan/worker_plan_internal/schedule/export_gantt_dhtmlx_template.html b/worker_plan/worker_plan_internal/schedule/export_gantt_dhtmlx_template.html index 2f4c7624a..dcab577ea 100644 --- a/worker_plan/worker_plan_internal/schedule/export_gantt_dhtmlx_template.html +++ b/worker_plan/worker_plan_internal/schedule/export_gantt_dhtmlx_template.html @@ -76,6 +76,7 @@

PLACEHOLDER_TITLE

+

Unoptimized waterfall. Parallel work not modelled here.

diff --git a/worker_plan/worker_plan_internal/schedule/tests/test_export_gantt_dhtmlx.py b/worker_plan/worker_plan_internal/schedule/tests/test_export_gantt_dhtmlx.py new file mode 100644 index 000000000..34111218b --- /dev/null +++ b/worker_plan/worker_plan_internal/schedule/tests/test_export_gantt_dhtmlx.py @@ -0,0 +1,39 @@ +import unittest +import tempfile +from datetime import date +from pathlib import Path +from worker_plan_internal.schedule.export_gantt_dhtmlx import ExportGanttDHTMLX +from worker_plan_internal.schedule.parse_schedule_input_data import parse_schedule_input_data +from worker_plan_internal.schedule.schedule import ProjectSchedule +from worker_plan_internal.utils.dedent_strip import dedent_strip + + +class TestExportGanttDHTMLX(unittest.TestCase): + def test_body_content_starts_with_waterfall_note(self): + # Arrange + activities = parse_schedule_input_data(dedent_strip(""" + Activity;Predecessor;Duration;Comment + A;-;3; + B;A;2; + """)) + for activity in activities: + activity.title = f"Title{activity.id}" + project_schedule = ProjectSchedule.create(activities) + + # Act + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "gantt.html" + ExportGanttDHTMLX.save(project_schedule, str(path), date(2025, 8, 4), title="Demo") + html = path.read_text(encoding="utf-8") + + # Assert + note = "Unoptimized waterfall. Parallel work not modelled here." + body_start = html.index("") + chart_start = html.index('id="gantt_container"') + note_pos = html.find(note) + self.assertNotEqual(note_pos, -1, "note is missing from the exported HTML") + self.assertTrue(body_start < note_pos < chart_start, "note must be at the top of the body content, before the chart") + + +if __name__ == "__main__": + unittest.main() From 48ef875ce2c02c1b07f2b98a99f4253ffa5af3fc Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Sun, 6 Sep 2026 13:22:07 +0200 Subject: [PATCH 2/2] Move the Gantt waterfall note from the dhtmlx template to a report subtitle The note now comes from ReportGenerator.append_html via a new optional subtitle argument, rendered as a plain paragraph the same way the Premortem intro line is. Putting it at the report layer means it shows up even when Luigi reuses a cached gantt.html from an earlier run, which is why it was missing from a regenerated report. Replaces the dhtmlx export test with tests for the subtitle in worker_plan/tests. Co-Authored-By: Claude Fable 5.1 --- worker_plan/tests/test_report_generator.py | 42 +++++++++++++++++++ .../worker_plan_internal/plan/nodes/report.py | 2 +- .../report/report_generator.py | 10 +++-- .../export_gantt_dhtmlx_template.html | 1 - .../tests/test_export_gantt_dhtmlx.py | 39 ----------------- 5 files changed, 50 insertions(+), 44 deletions(-) create mode 100644 worker_plan/tests/test_report_generator.py delete mode 100644 worker_plan/worker_plan_internal/schedule/tests/test_export_gantt_dhtmlx.py diff --git a/worker_plan/tests/test_report_generator.py b/worker_plan/tests/test_report_generator.py new file mode 100644 index 000000000..69b5425b6 --- /dev/null +++ b/worker_plan/tests/test_report_generator.py @@ -0,0 +1,42 @@ +import tempfile +import unittest +from pathlib import Path + +from worker_plan_internal.report.report_generator import ReportGenerator + + +class TestReportGeneratorAppendHtml(unittest.TestCase): + def test_subtitle_is_rendered_first_in_section_content(self): + with tempfile.TemporaryDirectory() as tmp: + html_path = Path(tmp) / "widget.html" + html_path.write_text( + "" + "
chart
" + "" + ) + rg = ReportGenerator() + rg.append_html("Gantt", html_path, subtitle="Unoptimized waterfall. Parallel work modelled here.") + html = rg.generate_html_report(title="Sample") + + button = html.index('') + subtitle = html.index("

Unoptimized waterfall. Parallel work <not> modelled here.

") + widget = html.index('id="widget"') + self.assertTrue(button < subtitle < widget) + + def test_no_subtitle_by_default(self): + with tempfile.TemporaryDirectory() as tmp: + html_path = Path(tmp) / "widget.html" + html_path.write_text( + "
chart
" + ) + rg = ReportGenerator() + rg.append_html("Gantt", html_path) + html = rg.generate_html_report(title="Sample") + + button = html.index('') + widget = html.index('id="widget"') + self.assertNotIn("

", html[button:widget]) + + +if __name__ == "__main__": + unittest.main() diff --git a/worker_plan/worker_plan_internal/plan/nodes/report.py b/worker_plan/worker_plan_internal/plan/nodes/report.py index 4eca7d73b..3a067ce23 100644 --- a/worker_plan/worker_plan_internal/plan/nodes/report.py +++ b/worker_plan/worker_plan_internal/plan/nodes/report.py @@ -73,7 +73,7 @@ def run_inner(self): rg = ReportGenerator() rg.append_markdown('Executive Summary', self.input()['executive_summary']['markdown'].path) - rg.append_html('Gantt', self.input()['create_schedule']['dhtmlx_html'].path) + rg.append_html('Gantt', self.input()['create_schedule']['dhtmlx_html'].path, subtitle='Unoptimized waterfall. Parallel work not modelled here.') rg.append_markdown('Pitch', self.input()['pitch_markdown']['markdown'].path) rg.append_markdown('Project Plan', self.input()['project_plan']['markdown'].path) rg.append_markdown('Strategic Decisions', self.input()['strategic_decisions_markdown']['markdown'].path) diff --git a/worker_plan/worker_plan_internal/report/report_generator.py b/worker_plan/worker_plan_internal/report/report_generator.py index 288efe946..bcbb92017 100644 --- a/worker_plan/worker_plan_internal/report/report_generator.py +++ b/worker_plan/worker_plan_internal/report/report_generator.py @@ -154,15 +154,19 @@ def append_csv(self, document_title: str, file_path: Path, css_classes: list[str markdown_content = f"```csv\n{csv_text}```" self.report_markdown_item_list.append(ReportMarkdownItem(document_title, markdown_content)) - def append_html(self, document_title: str, file_path: Path, css_classes: list[str] = []): + def append_html(self, document_title: str, file_path: Path, css_classes: list[str] = [], subtitle: Optional[str] = None): """Append an HTML document to the report. HTML-only: not added to the markdown report (the markdown output is intended for LLM consumption, and embedded JavaScript/HTML widgets are not useful there). + + subtitle: optional one-line intro shown as the first paragraph of the section, above the embedded HTML. """ with open(file_path, 'r') as f: html_raw = f.read() + subtitle_html = f"

{escape(subtitle)}

\n" if subtitle else "" + # Extract the html_head content between and html_head_match = re.search(r'(.*)', html_raw, re.DOTALL) if html_head_match: @@ -175,11 +179,11 @@ def append_html(self, document_title: str, file_path: Path, css_classes: list[st html_body_match = re.search(r'(.*)', html_raw, re.DOTALL) if html_body_match: html_body = html_body_match.group(1) - self.report_html_item_list.append(ReportDocumentItem(document_title, html_body)) + self.report_html_item_list.append(ReportDocumentItem(document_title, subtitle_html + html_body, css_classes=css_classes)) else: logging.warning(f"Document: '{document_title}'. Could not find HTML_BODY_CONTENT_START and HTML_BODY_CONTENT_END in {file_path}") # If no markers found, use the entire content as the body - self.report_html_item_list.append(ReportDocumentItem(document_title, html_raw, css_classes=css_classes)) + self.report_html_item_list.append(ReportDocumentItem(document_title, subtitle_html + html_raw, css_classes=css_classes)) # Extract the html_body_script content between and html_body_script_match = re.search(r'(.*)', html_raw, re.DOTALL) diff --git a/worker_plan/worker_plan_internal/schedule/export_gantt_dhtmlx_template.html b/worker_plan/worker_plan_internal/schedule/export_gantt_dhtmlx_template.html index dcab577ea..2f4c7624a 100644 --- a/worker_plan/worker_plan_internal/schedule/export_gantt_dhtmlx_template.html +++ b/worker_plan/worker_plan_internal/schedule/export_gantt_dhtmlx_template.html @@ -76,7 +76,6 @@

PLACEHOLDER_TITLE

-

Unoptimized waterfall. Parallel work not modelled here.

diff --git a/worker_plan/worker_plan_internal/schedule/tests/test_export_gantt_dhtmlx.py b/worker_plan/worker_plan_internal/schedule/tests/test_export_gantt_dhtmlx.py deleted file mode 100644 index 34111218b..000000000 --- a/worker_plan/worker_plan_internal/schedule/tests/test_export_gantt_dhtmlx.py +++ /dev/null @@ -1,39 +0,0 @@ -import unittest -import tempfile -from datetime import date -from pathlib import Path -from worker_plan_internal.schedule.export_gantt_dhtmlx import ExportGanttDHTMLX -from worker_plan_internal.schedule.parse_schedule_input_data import parse_schedule_input_data -from worker_plan_internal.schedule.schedule import ProjectSchedule -from worker_plan_internal.utils.dedent_strip import dedent_strip - - -class TestExportGanttDHTMLX(unittest.TestCase): - def test_body_content_starts_with_waterfall_note(self): - # Arrange - activities = parse_schedule_input_data(dedent_strip(""" - Activity;Predecessor;Duration;Comment - A;-;3; - B;A;2; - """)) - for activity in activities: - activity.title = f"Title{activity.id}" - project_schedule = ProjectSchedule.create(activities) - - # Act - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "gantt.html" - ExportGanttDHTMLX.save(project_schedule, str(path), date(2025, 8, 4), title="Demo") - html = path.read_text(encoding="utf-8") - - # Assert - note = "Unoptimized waterfall. Parallel work not modelled here." - body_start = html.index("") - chart_start = html.index('id="gantt_container"') - note_pos = html.find(note) - self.assertNotEqual(note_pos, -1, "note is missing from the exported HTML") - self.assertTrue(body_start < note_pos < chart_start, "note must be at the top of the body content, before the chart") - - -if __name__ == "__main__": - unittest.main()