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 <noreply@anthropic.com>
This commit is contained in:
parent
feaefba03c
commit
20f1e31119
2 changed files with 167 additions and 6 deletions
|
|
@ -9,21 +9,109 @@
|
||||||
<style>
|
<style>
|
||||||
.grid-container {
|
.grid-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat({{ column_count }}, auto); /* 7 columns for each piece of information */
|
grid-template-columns: repeat({{ column_count }}, auto);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
}
|
}
|
||||||
|
.heading {
|
||||||
.grid-item {
|
grid-column: 1 / {{ column_count + 1 }};
|
||||||
/* Additional styling for grid items can go here */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading {
|
/* Timeline */
|
||||||
grid-column: 1 / {{ column_count + 1 }}; /* Spans from the 1st line to the 7th line */
|
.conf-timeline {
|
||||||
|
position: relative;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.conf-tl-month {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-left: 1px solid #dee2e6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.conf-tl-month-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
font-size: 0.68em;
|
||||||
|
color: #6c757d;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.conf-tl-today {
|
||||||
|
position: absolute;
|
||||||
|
top: 22px;
|
||||||
|
height: calc(100% - 22px);
|
||||||
|
border-left: 2px solid #dc3545;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.conf-tl-bar {
|
||||||
|
position: absolute;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.conf-tl-bar a, .conf-tl-bar span {
|
||||||
|
display: block;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 0.72em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 18px;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% 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 %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3>Next 90 days</h3>
|
||||||
|
<div class="conf-timeline" style="height: {{ total_h }}px;">
|
||||||
|
|
||||||
|
{# Month markers #}
|
||||||
|
{% for m in timeline.months %}
|
||||||
|
<div class="conf-tl-month" style="left: {{ m.left_pct }}%;">
|
||||||
|
<span class="conf-tl-month-label">{{ m.label }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Today marker (always at left: 0) #}
|
||||||
|
<div class="conf-tl-today" style="left: 0;"></div>
|
||||||
|
|
||||||
|
{# 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 %}
|
||||||
|
<div class="conf-tl-bar"
|
||||||
|
style="left: {{ conf.left_pct }}%; width: {{ conf.width_pct }}%; top: {{ top_px }}px; background: {{ color }};"
|
||||||
|
title="{{ conf.label }}">
|
||||||
|
{% if conf.url %}
|
||||||
|
<a href="{{ conf.url }}">{{ conf.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span>{{ conf.name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro section(heading, item_list, badge) %}
|
{% macro section(heading, item_list, badge) %}
|
||||||
{% if item_list %}
|
{% if item_list %}
|
||||||
<div class="heading">
|
<div class="heading">
|
||||||
|
|
@ -49,6 +137,9 @@
|
||||||
|
|
||||||
<div class="container-fluid mt-2">
|
<div class="container-fluid mt-2">
|
||||||
<h1>Conferences</h1>
|
<h1>Conferences</h1>
|
||||||
|
|
||||||
|
{{ render_timeline(timeline) }}
|
||||||
|
|
||||||
<div class="grid-container">
|
<div class="grid-container">
|
||||||
{{ section("Current", current, "attending") }}
|
{{ section("Current", current, "attending") }}
|
||||||
{{ section("Future", future, "going") }}
|
{{ section("Future", future, "going") }}
|
||||||
|
|
|
||||||
70
web_view.py
70
web_view.py
|
|
@ -440,6 +440,73 @@ def _conference_description(conf: StrDict) -> str:
|
||||||
return "\n".join(lines) if lines else "Conference"
|
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:
|
def build_conference_ical(items: list[StrDict]) -> bytes:
|
||||||
"""Build iCalendar feed for all conferences."""
|
"""Build iCalendar feed for all conferences."""
|
||||||
lines = [
|
lines = [
|
||||||
|
|
@ -488,10 +555,13 @@ def conference_list() -> str:
|
||||||
]
|
]
|
||||||
future = [conf for conf in items if conf["start_date"] > today]
|
future = [conf for conf in items if conf["start_date"] > today]
|
||||||
|
|
||||||
|
timeline = build_conference_timeline(current, future, today)
|
||||||
|
|
||||||
return flask.render_template(
|
return flask.render_template(
|
||||||
"conference_list.html",
|
"conference_list.html",
|
||||||
current=current,
|
current=current,
|
||||||
future=future,
|
future=future,
|
||||||
|
timeline=timeline,
|
||||||
today=today,
|
today=today,
|
||||||
get_country=agenda.get_country,
|
get_country=agenda.get_country,
|
||||||
fx_rate=agenda.fx.get_rates(app.config),
|
fx_rate=agenda.fx.get_rates(app.config),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue