From 6776609878e273c6e96873ca3da42a8b64c83794 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Mon, 27 Apr 2026 17:56:08 +0100 Subject: [PATCH 1/2] Add timeline app This allows admins to configure a timeline that shows on the homepage. Events are rendered as bubbles with a label and the date shown beneath them. If the event is marked as complete, then the bubble is filled. The date is a free-text field, allowing both specific dates (e.g. "Feb 12th 2016") and vague dates (e.g. "Early May 2026"). Because what events should be shown can be specific to a particular year and what that year's Hugo admin wants to communicate, there is no automation tied to the timeline. It is up to the admin to remember to update vague dates with more specific ones, and mark events as completed once they are done. If no timeline events are configured, the timeline doesn't show, allowing the Hugo admin to opt in to whether they have the timeline or not. --- convention-template/config/settings.py.jinja | 2 + nomnom_dev/settings.py | 2 + src/nomnom/base/templates/nomnom/index.html | 3 + src/nomnom/base/views.py | 4 +- src/nomnom/nominate/static/css/layout.css | 86 +++++++++++++++++++ src/nomnom/test_settings.py | 1 + src/nomnom/timeline/__init__.py | 0 src/nomnom/timeline/admin.py | 10 +++ src/nomnom/timeline/apps.py | 16 ++++ src/nomnom/timeline/feature_switches.py | 3 + .../timeline/migrations/0001_initial.py | 51 +++++++++++ .../migrations/0002_create_timeline_switch.py | 38 ++++++++ src/nomnom/timeline/migrations/__init__.py | 0 src/nomnom/timeline/models.py | 24 ++++++ src/nomnom/timeline/receivers.py | 19 ++++ .../timeline/templates/timeline/timeline.html | 23 +++++ 16 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 src/nomnom/timeline/__init__.py create mode 100644 src/nomnom/timeline/admin.py create mode 100644 src/nomnom/timeline/apps.py create mode 100644 src/nomnom/timeline/feature_switches.py create mode 100644 src/nomnom/timeline/migrations/0001_initial.py create mode 100644 src/nomnom/timeline/migrations/0002_create_timeline_switch.py create mode 100644 src/nomnom/timeline/migrations/__init__.py create mode 100644 src/nomnom/timeline/models.py create mode 100644 src/nomnom/timeline/receivers.py create mode 100644 src/nomnom/timeline/templates/timeline/timeline.html diff --git a/convention-template/config/settings.py.jinja b/convention-template/config/settings.py.jinja index 38ef3b9a..0e4e6131 100644 --- a/convention-template/config/settings.py.jinja +++ b/convention-template/config/settings.py.jinja @@ -109,6 +109,8 @@ INSTALLED_APPS = [ "waffle", # Convention admin utilities and seeding commands "nomnom.convention_admin", + # Admin-configurable homepage timeline + "nomnom.timeline", ] SITE_ID = 1 diff --git a/nomnom_dev/settings.py b/nomnom_dev/settings.py index 9f93cc5a..39530d84 100644 --- a/nomnom_dev/settings.py +++ b/nomnom_dev/settings.py @@ -135,6 +135,8 @@ def __bool__(self): # if using Python 2, use __nonzero__ instead "nomnom.hugopacket", # Convention admin utilities and seeding commands "nomnom.convention_admin", + # Admin-configurable homepage timeline + "nomnom.timeline", ] SITE_ID = 1 diff --git a/src/nomnom/base/templates/nomnom/index.html b/src/nomnom/base/templates/nomnom/index.html index 64486983..cee540bb 100644 --- a/src/nomnom/base/templates/nomnom/index.html +++ b/src/nomnom/base/templates/nomnom/index.html @@ -6,6 +6,9 @@ {% if not user.is_authenticated %} {% include "bits/login_forms.html" %} {% else %} + {% switch "timeline" %} + {% include "timeline/timeline.html" %} + {% endswitch %}
diff --git a/src/nomnom/base/views.py b/src/nomnom/base/views.py index 1504c1f0..705fe349 100644 --- a/src/nomnom/base/views.py +++ b/src/nomnom/base/views.py @@ -5,12 +5,10 @@ def index(request: HttpRequest) -> HttpResponse: + context = {} if request.user.is_authenticated: - context = {} for receiver, response in index_content_load.send(sender=None, request=request): if isinstance(response, dict): context.update(response) - else: - context = {} return render(request, "nomnom/index.html", context) diff --git a/src/nomnom/nominate/static/css/layout.css b/src/nomnom/nominate/static/css/layout.css index c399a156..d5977834 100644 --- a/src/nomnom/nominate/static/css/layout.css +++ b/src/nomnom/nominate/static/css/layout.css @@ -70,3 +70,89 @@ position: relative; z-index: 1; } + +.timeline { + margin: 0 1rem 2rem 0; +} + +.timeline-events { + list-style: none; + margin: 0; + padding: 0 0 0 2rem; + display: flex; + flex-wrap: wrap; + align-items: flex-start; + row-gap: 1.25rem; +} + +.timeline-event { + flex: 1 1 3rem; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 0 0.5rem; + max-width: 8rem; +} + +.timeline-event:first-child { + margin-left: -2rem; +} + +.timeline-event::before, +.timeline-event::after { + content: ""; + position: absolute; + top: calc(0.75rem - 2.5px); + height: 5px; + background: var(--timeline-color, #555); + z-index: 0; +} + +.timeline-event::before { + left: 0; + right: 50%; +} + +.timeline-event::after { + left: 50%; + right: 0; +} + +.timeline-event:first-child::before, +.timeline-event:last-child::after { + content: none; +} + +.timeline-bubble { + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + border: 5px solid var(--timeline-color, #555); + background: #fff; + position: relative; + z-index: 1; + margin-bottom: 0.4rem; +} + +.timeline-event.is-complete .timeline-bubble { + background: var(--timeline-color, #555); +} + +.timeline-label { + font-weight: bold; + font-size: 0.8rem; + line-height: 1.15; +} + +.timeline-date { + font-size: 0.75rem; + margin-top: 0.15rem; +} + +.timeline-footnote { + font-size: 0.7rem; + margin-top: 0.5rem; + margin-bottom: 0; +} diff --git a/src/nomnom/test_settings.py b/src/nomnom/test_settings.py index 2916452a..ec0b3ec8 100644 --- a/src/nomnom/test_settings.py +++ b/src/nomnom/test_settings.py @@ -39,6 +39,7 @@ "nomnom.canonicalize", "nomnom.wsfs", "nomnom.convention_admin", + "nomnom.timeline", ] NPLUSONE_RAISE = True diff --git a/src/nomnom/timeline/__init__.py b/src/nomnom/timeline/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/nomnom/timeline/admin.py b/src/nomnom/timeline/admin.py new file mode 100644 index 00000000..1c5626ce --- /dev/null +++ b/src/nomnom/timeline/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from . import models + + +@admin.register(models.TimelineEvent) +class TimelineEventAdmin(admin.ModelAdmin): + list_display = ["label", "date", "provisional", "position", "complete"] + list_editable = ["position", "complete"] + ordering = ["position"] diff --git a/src/nomnom/timeline/apps.py b/src/nomnom/timeline/apps.py new file mode 100644 index 00000000..9730f704 --- /dev/null +++ b/src/nomnom/timeline/apps.py @@ -0,0 +1,16 @@ +from django.apps import AppConfig + + +class TimelineConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "nomnom.timeline" + + def ready(self) -> None: + self.enable_signals() + + return super().ready() + + def enable_signals(self): + from . import ( + receivers, # noqa: F401 + ) diff --git a/src/nomnom/timeline/feature_switches.py b/src/nomnom/timeline/feature_switches.py new file mode 100644 index 00000000..cab96f85 --- /dev/null +++ b/src/nomnom/timeline/feature_switches.py @@ -0,0 +1,3 @@ +"""Waffle switch name constants for the timeline feature.""" + +SWITCH_TIMELINE = "timeline" diff --git a/src/nomnom/timeline/migrations/0001_initial.py b/src/nomnom/timeline/migrations/0001_initial.py new file mode 100644 index 00000000..ab8c5329 --- /dev/null +++ b/src/nomnom/timeline/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.11 on 2026-04-24 19:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="TimelineEvent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("label", models.CharField(max_length=255)), + ( + "date", + models.CharField( + help_text="e.g. 'Feb 12th 2026' or 'Early May 2026'.", + max_length=255, + ), + ), + ("position", models.PositiveSmallIntegerField(default=0)), + ( + "provisional", + models.BooleanField( + default=False, + help_text="Whether to show a caveat about the date being provisional.", + ), + ), + ( + "complete", + models.BooleanField( + default=False, help_text="Whether the event has passed." + ), + ), + ], + options={ + "ordering": ["position"], + }, + ), + ] diff --git a/src/nomnom/timeline/migrations/0002_create_timeline_switch.py b/src/nomnom/timeline/migrations/0002_create_timeline_switch.py new file mode 100644 index 00000000..ec506729 --- /dev/null +++ b/src/nomnom/timeline/migrations/0002_create_timeline_switch.py @@ -0,0 +1,38 @@ +"""Data migration to create a feature flag toggle for the homepage timeline.""" + +from django.db import migrations + +from nomnom.timeline.feature_switches import SWITCH_TIMELINE + +SWITCHES = [ + { + "name": SWITCH_TIMELINE, + "active": False, + "note": "Toggle homepage timeline visibility", + }, +] + + +def create_switches(apps, schema_editor): + Switch = apps.get_model("waffle", "Switch") + for switch in SWITCHES: + Switch.objects.get_or_create( + name=switch["name"], + defaults={"active": switch["active"], "note": switch["note"]}, + ) + + +def delete_switches(apps, schema_editor): + Switch = apps.get_model("waffle", "Switch") + Switch.objects.filter(name__in=[s["name"] for s in SWITCHES]).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("waffle", "0004_update_everyone_nullbooleanfield"), + ("timeline", "0001_initial"), + ] + + operations = [ + migrations.RunPython(create_switches, delete_switches), + ] diff --git a/src/nomnom/timeline/migrations/__init__.py b/src/nomnom/timeline/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/nomnom/timeline/models.py b/src/nomnom/timeline/models.py new file mode 100644 index 00000000..2061a2df --- /dev/null +++ b/src/nomnom/timeline/models.py @@ -0,0 +1,24 @@ +from django.db import models + + +class TimelineEvent(models.Model): + class Meta: + ordering = ["position"] + + label = models.CharField(max_length=255) + date = models.CharField( + max_length=255, + help_text="e.g. 'Feb 12th 2026' or 'Early May 2026'.", + ) + position = models.PositiveSmallIntegerField(default=0) + provisional = models.BooleanField( + default=False, + help_text="Whether to show a caveat about the date being provisional.", + ) + complete = models.BooleanField( + default=False, + help_text="Whether the event has passed.", + ) + + def __str__(self) -> str: + return self.label diff --git a/src/nomnom/timeline/receivers.py b/src/nomnom/timeline/receivers.py new file mode 100644 index 00000000..a12d67d5 --- /dev/null +++ b/src/nomnom/timeline/receivers.py @@ -0,0 +1,19 @@ +from waffle import switch_is_active + +from nomnom.base.signals import index_content_load +from nomnom.timeline.feature_switches import SWITCH_TIMELINE + +from . import models + + +def add_timeline_content(sender, request, **kwargs): + if not switch_is_active(SWITCH_TIMELINE): + return {} + timeline_events = list(models.TimelineEvent.objects.all()) + return { + "timeline_events": timeline_events, + "any_provisional": any(e.provisional for e in timeline_events), + } + + +index_content_load.connect(add_timeline_content) diff --git a/src/nomnom/timeline/templates/timeline/timeline.html b/src/nomnom/timeline/templates/timeline/timeline.html new file mode 100644 index 00000000..6b32953a --- /dev/null +++ b/src/nomnom/timeline/templates/timeline/timeline.html @@ -0,0 +1,23 @@ +{% load i18n %} +{% if timeline_events %} +
+
    + {% for event in timeline_events %} +
  1. + + {{ event.label }} + + {{ event.date }} + {% if event.provisional %} + + ({% translate "provisional" %}) + {% endif %} + +
  2. + {% endfor %} +
+ {% if any_provisional %} + + {% endif %} +
+{% endif %} From e9d0dea85e16e575a9877daca457da9744f772f9 Mon Sep 17 00:00:00 2001 From: Andrew January Date: Tue, 28 Apr 2026 16:42:06 +0100 Subject: [PATCH 2/2] Render timeline vertically on small screens Rather than allowing the timeline to wrap, on small screens switch to a vertical view. In order to avoid taking up all the screen real estate in the vertical view, default to rendering it collapsed so only the "current" time is shown. This is the first transition from a completed to a non-completed event. The timeline can then be interacted with to toggle the non-collapsed view. --- src/nomnom/nominate/static/css/layout.css | 119 ++++++++++++++++++ src/nomnom/timeline/receivers.py | 10 ++ .../timeline/templates/timeline/timeline.html | 20 ++- 3 files changed, 145 insertions(+), 4 deletions(-) diff --git a/src/nomnom/nominate/static/css/layout.css b/src/nomnom/nominate/static/css/layout.css index d5977834..85115c19 100644 --- a/src/nomnom/nominate/static/css/layout.css +++ b/src/nomnom/nominate/static/css/layout.css @@ -75,6 +75,11 @@ margin: 0 1rem 2rem 0; } +.timeline-toggle, +.timeline-toggle-label { + display: none; +} + .timeline-events { list-style: none; margin: 0; @@ -156,3 +161,117 @@ margin-top: 0.5rem; margin-bottom: 0; } + +@media (max-width: 767.98px) { + .timeline-events { + flex-direction: column; + align-items: flex-start; + flex-wrap: nowrap; + padding-left: 0; + row-gap: 0; + } + + .timeline-event { + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + column-gap: 0.75rem; + flex: 0 0 auto; + max-width: none; + padding: 0.5rem 0; + width: 100%; + text-align: left; + } + + .timeline-event:first-child { + margin-left: 0; + } + + .timeline-bubble { + grid-column: 1; + grid-row: 1 / span 2; + align-self: center; + margin-bottom: 0; + } + + .timeline-label { + grid-column: 2; + grid-row: 1; + align-self: end; + } + + .timeline-date { + grid-column: 2; + grid-row: 2; + align-self: start; + } + + .timeline-event::before, + .timeline-event::after { + left: calc(0.75rem - 2.5px); + right: auto; + top: auto; + width: 5px; + height: 50%; + } + + .timeline-event::before { + top: 0; + } + + .timeline-event::after { + bottom: 0; + } + + .timeline-label, + .timeline-date { + display: block; + text-align: left; + } + + .timeline { + position: relative; + } + + .timeline-toggle { + display: block; + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .timeline-toggle-label { + display: block; + position: absolute; + inset: 0; + cursor: pointer; + z-index: 2; + } + + .timeline-toggle:focus-visible ~ .timeline-toggle-label { + outline: auto; + outline-offset: 2px; + } + + .timeline-toggle:not(:checked) ~ .timeline-events .timeline-event:not(.timeline-event--shown) { + display: none; + } + + .timeline-toggle:not(:checked) ~ .timeline-events .timeline-event:not(.timeline-event--shown) + .timeline-event--shown::before { + background: + linear-gradient(var(--timeline-color, #555), var(--timeline-color, #555)) center top 2px / 5px 5px no-repeat, + linear-gradient(var(--timeline-color, #555), var(--timeline-color, #555)) center top 10px / 5px 5px no-repeat; + } + + .timeline-toggle:not(:checked) ~ .timeline-events .timeline-event--shown:has(+ .timeline-event:not(.timeline-event--shown))::after { + background: + linear-gradient(var(--timeline-color, #555), var(--timeline-color, #555)) center bottom 2px / 5px 5px no-repeat, + linear-gradient(var(--timeline-color, #555), var(--timeline-color, #555)) center bottom 10px / 5px 5px no-repeat; + } +} diff --git a/src/nomnom/timeline/receivers.py b/src/nomnom/timeline/receivers.py index a12d67d5..fa9e9a3e 100644 --- a/src/nomnom/timeline/receivers.py +++ b/src/nomnom/timeline/receivers.py @@ -10,10 +10,20 @@ def add_timeline_content(sender, request, **kwargs): if not switch_is_active(SWITCH_TIMELINE): return {} timeline_events = list(models.TimelineEvent.objects.all()) + _annotate_collapsed_visibility(timeline_events) return { "timeline_events": timeline_events, "any_provisional": any(e.provisional for e in timeline_events), } +def _annotate_collapsed_visibility(events): + boundary = next( + (i - 1 for i, e in enumerate(events) if not e.complete), + len(events) - 1, + ) + for i, event in enumerate(events): + event.shown_collapsed = i in (boundary, boundary + 1, boundary + 2) + + index_content_load.connect(add_timeline_content) diff --git a/src/nomnom/timeline/templates/timeline/timeline.html b/src/nomnom/timeline/templates/timeline/timeline.html index 6b32953a..0c1e1b18 100644 --- a/src/nomnom/timeline/templates/timeline/timeline.html +++ b/src/nomnom/timeline/templates/timeline/timeline.html @@ -1,11 +1,23 @@ {% load i18n %} {% if timeline_events %} -
-
    +
    + + +
      {% for event in timeline_events %} -
    1. +
    2. {{ event.label }} + + {% if event.complete %} + ({% translate "completed" %}) + {% endif %} + {{ event.date }} {% if event.provisional %} @@ -19,5 +31,5 @@ {% if any_provisional %} {% endif %} -
+ {% endif %}