diff --git a/templates/conference_list.html b/templates/conference_list.html
index a900f7d..7ac5ddc 100644
--- a/templates/conference_list.html
+++ b/templates/conference_list.html
@@ -1,59 +1,250 @@
{% 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 %}
-{% macro section(heading, item_list, badge) %}
- {% if item_list %}
-
+{% set tl_colors = ["#0d6efd","#198754","#dc3545","#fd7e14","#6f42c1","#20c997","#0dcaf0","#d63384"] %}
-
{{ heading }}
+{% macro render_timeline(timeline) %}
+{% if timeline %}
+{% set row_h = 32 %}
+{% set header_h = 22 %}
+{% set total_h = timeline.lane_count * row_h + header_h %}
+
+
Next 90 days
+
-
- {% set item_count = item_list|length %}
- {% if item_count == 1 %}{{ item_count }} conference{% else %}{{ item_count }} conferences{% endif %}
-
-
-
- {% for item in item_list %}
- {{ conference_row(item, badge) }}
-
- {% if item.linked_trip %} trip: {{ trip_link(item.linked_trip) }} {% endif %}
+ {% for m in timeline.months %}
+
+ {{ m.label }}
{% endfor %}
- {% endif %}
+
+
+
+ {% 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 conf_table(heading, item_list, badge) %}
+{% if item_list %}
+{% set count = item_list | length %}
+
{{ heading }} {{ count }} conference{{ "" if count == 1 else "s" }}
+
+
+
+
+
+
+
+
+
+
+
+ | Dates |
+ Conference |
+ Topic |
+ Location |
+ CFP ends |
+ Price |
+
+
+
+ {% set ns = namespace(prev_month="") %}
+ {% for item in item_list %}
+ {% set month_label = item.start_date.strftime("%B %Y") %}
+ {% if month_label != ns.prev_month %}
+ {% set ns.prev_month = month_label %}
+
+ | {{ month_label }} |
+
+ {% endif %}
+
+ |
+ {%- 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 %}
+ |
+
+ {% endfor %}
+
+
+{% endif %}
{% endmacro %}
{% block content %}
Conferences
-
- {{ section("Current", current, "attending") }}
- {{ section("Future", future, "going") }}
- {{ section("Past", past|reverse|list, "went") }}
-
+
+ {{ render_timeline(timeline) }}
+
+ {{ conf_table("Current", current, "attending") }}
+ {{ conf_table("Future", future, "going") }}
+ {{ conf_table("Past", past|reverse|list, "went") }}
+
+
{% endblock %}
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()
diff --git a/web_view.py b/web_view.py
index 4bd5203..8ba8b25 100755
--- a/web_view.py
+++ b/web_view.py
@@ -440,6 +440,74 @@ 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,
+ "key": f"{conf['start_date'].isoformat()}|{conf['name']}",
+ "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 +556,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),