From 20f1e3111953fe80b28991dc5c1784f9707f1230 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 14 Mar 2026 10:47:46 +0000 Subject: [PATCH 1/5] 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), From ef517c98ff2163a850b2145a96716404129e1141 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 14 Mar 2026 10:56:44 +0000 Subject: [PATCH 2/5] Replace CSS grid with Bootstrap table on conference list page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Proper with colgroup widths, table-sm table-hover align-middle - Month-divider rows (MARCH 2026, APRIL 2026, …) break up long lists - Date ranges collapsed to a single column (e.g. "25–28 Mar 2026") - Row highlight (conf-going) for conferences marked going=true - Topic and date columns styled text-muted small to reduce visual noise - Trip links replaced with 🧳 emoji: shows just the emoji when the trip title matches the conference name (the common case), otherwise appends the trip title (e.g. "🧳 Budapest" for FOSDEM); full title always in tooltip Co-Authored-By: Claude Sonnet 4.6 --- templates/conference_list.html | 152 +++++++++++++++++++++++---------- 1 file changed, 109 insertions(+), 43 deletions(-) diff --git a/templates/conference_list.html b/templates/conference_list.html index bb98d84..6aef0f0 100644 --- a/templates/conference_list.html +++ b/templates/conference_list.html @@ -1,22 +1,11 @@ {% extends "base.html" %} -{% from "macros.html" import trip_link, conference_row with context %} +{% from "macros.html" import trip_link with context %} {% block title %}Conferences - Edward Betts{% endblock %} {% block style %} -{% set column_count = 9 %} {% endblock %} @@ -74,7 +79,6 @@ {% 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 %} @@ -82,28 +86,22 @@

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 %} + {% if conf.url %}{{ conf.name }} + {% else %}{{ conf.name }}{% endif %}
{% endfor %} @@ -112,25 +110,95 @@ {% endif %} {% endmacro %} -{% macro section(heading, item_list, badge) %} - {% if item_list %} -
- -

{{ heading }}

- -

- {% set item_count = item_list|length %} - {% if item_count == 1 %}{{ item_count }} conference{% else %}{{ item_count }} conferences{% endif %} -

-
- +{% macro conf_table(heading, item_list, badge) %} +{% if item_list %} +{% set count = item_list | length %} +

{{ heading }} {{ count }} conference{{ "" if count == 1 else "s" }}

+
+ + + + + + + + + + + + + + + + + + + + {% set ns = namespace(prev_month="") %} {% for item in item_list %} - {{ conference_row(item, badge) }} -
- {% if item.linked_trip %} trip: {{ trip_link(item.linked_trip) }} {% endif %} -
+ {% set month_label = item.start_date.strftime("%B %Y") %} + {% if month_label != ns.prev_month %} + {% set ns.prev_month = month_label %} + + + + {% endif %} + + + + + + + + {% endfor %} - {% endif %} + +
DatesConferenceTopicLocationCFP endsPrice
{{ month_label }}
+ {%- if item.start_date == item.end_date -%} + {{ item.start_date.strftime("%-d %b %Y") }} + {%- elif item.start_date.year == item.end_date.year and item.start_date.month == item.end_date.month -%} + {{ item.start_date.strftime("%-d") }}–{{ item.end_date.strftime("%-d %b %Y") }} + {%- else -%} + {{ item.start_date.strftime("%-d %b") }}–{{ item.end_date.strftime("%-d %b %Y") }} + {%- endif -%} + + {% if item.url %}{{ item.name }} + {% else %}{{ item.name }}{% endif %} + {% if item.going and not (item.accommodation_booked or item.travel_booked) %} + {{ badge }} + {% endif %} + {% if item.accommodation_booked %} + accommodation + {% endif %} + {% if item.transport_booked %} + transport + {% endif %} + {% if item.linked_trip %} + {% set trip = item.linked_trip %} + + 🧳{% if trip.title != item.name %} {{ trip.title }}{% endif %} + + {% endif %} + {{ item.topic }} + {% set country = get_country(item.country) if item.country else None %} + {% if country %}{{ country.flag }} {{ item.location }} + {% elif item.online %}💻 Online + {% else %}{{ item.location }}{% endif %} + + {% if item.cfp_end %}{{ item.cfp_end.strftime("%-d %b %Y") }}{% endif %} + + {% if item.price and item.currency %} + {{ "{:,d}".format(item.price | int) }} {{ item.currency }} + {% if item.currency != "GBP" and item.currency in fx_rate %} + {{ "{:,.0f}".format(item.price / fx_rate[item.currency]) }} GBP + {% endif %} + {% elif item.free %} + free + {% endif %} +
+{% endif %} {% endmacro %} {% block content %} @@ -140,11 +208,9 @@ {{ render_timeline(timeline) }} -
- {{ section("Current", current, "attending") }} - {{ section("Future", future, "going") }} - {{ section("Past", past|reverse|list, "went") }} -
+ {{ conf_table("Current", current, "attending") }} + {{ conf_table("Future", future, "going") }} + {{ conf_table("Past", past|reverse|list, "went") }}
{% endblock %} From 10716f98741c500f8b96c1e85eef8ffd296fe7bd Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 14 Mar 2026 11:02:55 +0000 Subject: [PATCH 3/5] Add bidirectional hover highlight between Gantt chart and table Each Gantt bar and table row gets a data-conf-key attribute (ISO start date + conference name). A small JS lookup map connects them so hovering either element highlights both simultaneously: - Gantt bar: filter brightness + inset white box-shadow - Table row: yellow tint via background-color Hovering a Gantt bar also scrolls the matching table row into view (scrollIntoView nearest) so future conferences are reachable without manual scrolling. The key field is pre-computed in build_conference_timeline() to keep the template simple. Co-Authored-By: Claude Sonnet 4.6 --- templates/conference_list.html | 43 ++++++++++++++++++++++++++++++++-- web_view.py | 1 + 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/templates/conference_list.html b/templates/conference_list.html index 6aef0f0..11828ba 100644 --- a/templates/conference_list.html +++ b/templates/conference_list.html @@ -57,6 +57,16 @@ text-decoration: none; } +/* Bidirectional hover highlight */ +.conf-tl-bar.conf-hl { + filter: brightness(1.25); + box-shadow: inset 0 0 0 2px rgba(255,255,255,0.8); + z-index: 15; +} +tr.conf-hl > td { + background-color: rgba(255, 193, 7, 0.25) !important; +} + /* Conference table */ .conf-month-row td { background: #e9ecef !important; @@ -99,7 +109,8 @@ {% set top_px = conf.lane * row_h + header_h %}
+ title="{{ conf.label }}" + data-conf-key="{{ conf.key }}"> {% if conf.url %}{{ conf.name }} {% else %}{{ conf.name }}{% endif %}
@@ -143,7 +154,7 @@ {{ month_label }} {% endif %} - + {%- if item.start_date == item.end_date -%} {{ item.start_date.strftime("%-d %b %Y") }} @@ -213,4 +224,32 @@ {{ conf_table("Past", past|reverse|list, "went") }}
+ + {% endblock %} diff --git a/web_view.py b/web_view.py index acdf4d2..8ba8b25 100755 --- a/web_view.py +++ b/web_view.py @@ -482,6 +482,7 @@ def build_conference_timeline( "lane": lane, "left_pct": left_pct, "width_pct": width_pct, + "key": f"{conf['start_date'].isoformat()}|{conf['name']}", "label": ( f"{conf['name']}" f" ({conf['start_date'].strftime('%-d %b')}–" From bca0cd27273d492fe32bc43d3ac4a921db376cca Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 14 Mar 2026 11:05:32 +0000 Subject: [PATCH 4/5] Minor cleanup in update.py - Move datetime import to module level - Replace assert on load_cached_launches with or {} fallback so the updater doesn't crash when the cache file is absent - Condense upcoming-trips list comprehension to one line Co-Authored-By: Claude Sonnet 4.6 --- update.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/update.py b/update.py index 00460c2..efa4bd9 100755 --- a/update.py +++ b/update.py @@ -29,6 +29,9 @@ from agenda.types import StrDict from web_view import app +from datetime import date, timedelta + + async def update_bank_holidays(config: flask.config.Config) -> None: """Update cached copy of UK Bank holidays.""" t0 = time() @@ -318,8 +321,7 @@ def update_thespacedevs(config: flask.config.Config) -> None: """ rocket_dir = os.path.join(config["DATA_DIR"], "thespacedevs") - existing_data = agenda.thespacedevs.load_cached_launches(rocket_dir) - assert existing_data + existing_data = agenda.thespacedevs.load_cached_launches(rocket_dir) or {} if agenda.thespacedevs.is_launches_cache_fresh(rocket_dir): return @@ -411,16 +413,13 @@ def update_gandi(config: flask.config.Config) -> None: def update_weather(config: flask.config.Config) -> None: """Refresh weather cache for home and all upcoming trips.""" - from datetime import date, timedelta today = date.today() forecast_window = today + timedelta(days=8) trips = agenda.trip.build_trip_list() upcoming = [ - t - for t in trips - if (t.end or t.start) >= today and t.start <= forecast_window + t for t in trips if (t.end or t.start) >= today and t.start <= forecast_window ] seen: set[tuple[float, float]] = set() From 57054bb1bd20d96b5e985a1924afd0526fedbc9f Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 14 Mar 2026 11:07:02 +0000 Subject: [PATCH 5/5] Remove auto-scroll on Gantt bar hover Co-Authored-By: Claude Sonnet 4.6 --- templates/conference_list.html | 5 ----- 1 file changed, 5 deletions(-) diff --git a/templates/conference_list.html b/templates/conference_list.html index 11828ba..7ac5ddc 100644 --- a/templates/conference_list.html +++ b/templates/conference_list.html @@ -236,11 +236,6 @@ tr.conf-hl > td { function setHighlight(key, on) { (map.get(key) || []).forEach(el => el.classList.toggle('conf-hl', on)); - // When hovering a Gantt bar, scroll the matching table row into view - if (on) { - const row = (map.get(key) || []).find(el => el.tagName === 'TR'); - if (row) row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } } map.forEach((els, key) => {