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..85115c19 100644
--- a/src/nomnom/nominate/static/css/layout.css
+++ b/src/nomnom/nominate/static/css/layout.css
@@ -70,3 +70,208 @@
position: relative;
z-index: 1;
}
+
+.timeline {
+ margin: 0 1rem 2rem 0;
+}
+
+.timeline-toggle,
+.timeline-toggle-label {
+ display: none;
+}
+
+.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;
+}
+
+@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/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..fa9e9a3e
--- /dev/null
+++ b/src/nomnom/timeline/receivers.py
@@ -0,0 +1,29 @@
+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())
+ _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
new file mode 100644
index 00000000..0c1e1b18
--- /dev/null
+++ b/src/nomnom/timeline/templates/timeline/timeline.html
@@ -0,0 +1,35 @@
+{% load i18n %}
+{% if timeline_events %}
+
+{% endif %}