From 20f1e3111953fe80b28991dc5c1784f9707f1230 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 14 Mar 2026 10:47:46 +0000 Subject: [PATCH] Add Gantt-style timeline to conference list page Shows the next 90 days as a horizontal bar chart above the conference list. A greedy interval-colouring algorithm assigns each conference to a lane so overlapping conferences appear in separate rows. Lane colour cycles through a fixed palette so simultaneous events are easy to tell apart. Month boundary markers and a red today-line provide orientation. All position maths happens in build_conference_timeline() in Python; the Jinja template is pure rendering. Co-Authored-By: Claude Sonnet 4.6 --- templates/conference_list.html | 103 +++++++++++++++++++++++++++++++-- web_view.py | 70 ++++++++++++++++++++++ 2 files changed, 167 insertions(+), 6 deletions(-) diff --git a/templates/conference_list.html b/templates/conference_list.html index a900f7d..bb98d84 100644 --- a/templates/conference_list.html +++ b/templates/conference_list.html @@ -9,21 +9,109 @@ {% endblock %} +{% set tl_colors = ["#0d6efd","#198754","#dc3545","#fd7e14","#6f42c1","#20c997","#0dcaf0","#d63384"] %} + +{% macro render_timeline(timeline) %} +{% if timeline %} +{% set bar_h = 26 %} +{% set row_h = 32 %} +{% set header_h = 22 %} +{% set total_h = timeline.lane_count * row_h + header_h %} +
+

Next 90 days

+
+ + {# Month markers #} + {% for m in timeline.months %} +
+ {{ m.label }} +
+ {% endfor %} + + {# Today marker (always at left: 0) #} +
+ + {# Conference bars #} + {% for conf in timeline.confs %} + {% set color = tl_colors[conf.lane % tl_colors | length] %} + {% set top_px = conf.lane * row_h + header_h %} +
+ {% if conf.url %} + {{ conf.name }} + {% else %} + {{ conf.name }} + {% endif %} +
+ {% endfor %} + +
+
+{% endif %} +{% endmacro %} + {% macro section(heading, item_list, badge) %} {% if item_list %}
@@ -49,6 +137,9 @@

Conferences

+ + {{ render_timeline(timeline) }} +
{{ section("Current", current, "attending") }} {{ section("Future", future, "going") }} diff --git a/web_view.py b/web_view.py index 4bd5203..acdf4d2 100755 --- a/web_view.py +++ b/web_view.py @@ -440,6 +440,73 @@ def _conference_description(conf: StrDict) -> str: return "\n".join(lines) if lines else "Conference" +def build_conference_timeline( + current: list[StrDict], future: list[StrDict], today: date, days: int = 90 +) -> dict | None: + """Build data for a Gantt-style timeline of upcoming conferences.""" + timeline_start = today + timeline_end = today + timedelta(days=days) + + visible = [ + c + for c in (current + future) + if c["start_date"] <= timeline_end and c["end_date"] >= today + ] + if not visible: + return None + + visible.sort(key=lambda c: c["start_date"]) + + # Greedy interval-coloring: assign each conference a lane (row) + lane_ends: list[date] = [] + conf_data = [] + for conf in visible: + lane = next( + (i for i, end in enumerate(lane_ends) if end < conf["start_date"]), + len(lane_ends), + ) + if lane == len(lane_ends): + lane_ends.append(conf["end_date"]) + else: + lane_ends[lane] = conf["end_date"] + + start_off = max((conf["start_date"] - timeline_start).days, 0) + end_off = min((conf["end_date"] - timeline_start).days + 1, days) + left_pct = round(start_off / days * 100, 2) + width_pct = max(round((end_off - start_off) / days * 100, 2), 0.5) + + conf_data.append( + { + "name": conf["name"], + "url": conf.get("url"), + "lane": lane, + "left_pct": left_pct, + "width_pct": width_pct, + "label": ( + f"{conf['name']}" + f" ({conf['start_date'].strftime('%-d %b')}–" + f"{conf['end_date'].strftime('%-d %b')})" + ), + } + ) + + # Month markers for x-axis labels + months = [] + d = today.replace(day=1) + while d <= timeline_end: + off = max((d - timeline_start).days, 0) + months.append({"label": d.strftime("%b %Y"), "left_pct": round(off / days * 100, 2)}) + # advance to next month + d = (d.replace(day=28) + timedelta(days=4)).replace(day=1) + + return { + "confs": conf_data, + "lane_count": len(lane_ends), + "months": months, + "days": days, + } + + def build_conference_ical(items: list[StrDict]) -> bytes: """Build iCalendar feed for all conferences.""" lines = [ @@ -488,10 +555,13 @@ def conference_list() -> str: ] future = [conf for conf in items if conf["start_date"] > today] + timeline = build_conference_timeline(current, future, today) + return flask.render_template( "conference_list.html", current=current, future=future, + timeline=timeline, today=today, get_country=agenda.get_country, fx_rate=agenda.fx.get_rates(app.config),