fix(timeline): ensure cards always alternate sides across month boundaries

Per-month <ol> containers caused :nth-of-type to reset at each month,
putting the first card of consecutive months on the same side. Flatten
all items into a single container and use :nth-child(odd of .timeline-item)
for continuous left/right alternation across months and infinite scroll.
This commit is contained in:
Jon Seager 2026-02-03 09:51:48 +00:00
parent 80849f2bf5
commit 4ed1544d66
No known key found for this signature in database
3 changed files with 149 additions and 147 deletions

View file

@ -7,11 +7,9 @@
</svg> </svg>
</button> </button>
</h2> </h2>
<ol class="timeline-list relative"> </div>
{# Central timeline line #} {% for event in month.events %}
<div class="timeline-line absolute top-0 bottom-0 w-0.5 bg-amber-200" aria-hidden="true"></div> <div class="timeline-item relative mb-8" data-timeline-event>
{% for event in month.events %}
<li class="timeline-item relative mb-8 last:mb-0" data-timeline-event>
{# Timeline node/bullet #} {# Timeline node/bullet #}
<span <span
class="timeline-node absolute h-5 w-5 rounded-full border-4 border-amber-50 bg-amber-600" class="timeline-node absolute h-5 w-5 rounded-full border-4 border-amber-50 bg-amber-600"
@ -109,7 +107,5 @@
</ul> </ul>
{% endif %} {% endif %} {% endif %} {% endif %}
</div> </div>
</li>
{% endfor %}
</ol>
</div> </div>
{% endfor %}

View file

@ -118,24 +118,24 @@ a {
padding-right: 0; padding-right: 0;
} }
/* Left side items (odd) - uses nth-of-type to skip the timeline-line div */ /* Left side items (odd) - counts only .timeline-item children, skipping month headings */
.timeline-item:nth-of-type(odd) { .timeline-item:nth-child(odd of .timeline-item) {
align-self: flex-start; align-self: flex-start;
padding-right: 2rem; padding-right: 2rem;
} }
.timeline-item:nth-of-type(odd) .timeline-node { .timeline-item:nth-child(odd of .timeline-item) .timeline-node {
right: -10px; right: -10px;
left: auto; left: auto;
} }
/* Right side items (even) */ /* Right side items (even) */
.timeline-item:nth-of-type(even) { .timeline-item:nth-child(even of .timeline-item) {
align-self: flex-end; align-self: flex-end;
padding-left: 2rem; padding-left: 2rem;
} }
.timeline-item:nth-of-type(even) .timeline-node { .timeline-item:nth-child(even of .timeline-item) .timeline-node {
left: -10px; left: -10px;
} }

View file

@ -8,9 +8,7 @@
<div class="mt-8"> <div class="mt-8">
<section <section
class="space-y-12"
id="timeline-events" id="timeline-events"
data-role="timeline-months"
data-empty="{{ months.is_empty() }}" data-empty="{{ months.is_empty() }}"
> >
{% if months.is_empty() %} {% if months.is_empty() %}
@ -20,7 +18,12 @@
> >
No events yet. No events yet.
</p> </p>
{% else %} {% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %} {% else %}
<div class="timeline-list relative" id="timeline-items">
{# Single central timeline line for all months #}
<div class="timeline-line absolute top-0 bottom-0 w-0.5 bg-amber-200" aria-hidden="true"></div>
{% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %}
</div>
{% endif %} {% endif %}
<div <div
@ -49,15 +52,16 @@
<script type="module"> <script type="module">
const loader = document.getElementById("timeline-loader") const loader = document.getElementById("timeline-loader")
const monthsContainer = document.getElementById("timeline-events") const itemsContainer = document.getElementById("timeline-items")
const loadMoreButton = document.getElementById("timeline-load-more") const loadMoreButton = document.getElementById("timeline-load-more")
const statusLine = document.getElementById("timeline-status") const statusLine = document.getElementById("timeline-status")
const endLine = document.getElementById("timeline-end") const endLine = document.getElementById("timeline-end")
const errorLine = document.getElementById("timeline-error") const errorLine = document.getElementById("timeline-error")
const sentinel = document.getElementById("timeline-sentinel") const sentinel = document.getElementById("timeline-sentinel")
const emptyState = document.querySelector('[data-role="timeline-empty-state"]') const emptyState = document.querySelector('[data-role="timeline-empty-state"]')
const section = document.getElementById("timeline-events")
if (loader && monthsContainer && sentinel) { if (loader && sentinel) {
let nextUrl = loader.dataset.nextUrl || "" let nextUrl = loader.dataset.nextUrl || ""
let hasMorePages = loader.dataset.hasMore === "true" let hasMorePages = loader.dataset.hasMore === "true"
let loading = false let loading = false
@ -84,29 +88,32 @@
} }
} }
const ensureItemsContainer = () => {
if (itemsContainer) return itemsContainer
// First load on a previously empty timeline: create the container
const container = document.createElement("div")
container.className = "timeline-list relative"
container.id = "timeline-items"
const line = document.createElement("div")
line.className = "timeline-line absolute top-0 bottom-0 w-0.5 bg-amber-200"
line.setAttribute("aria-hidden", "true")
container.appendChild(line)
section.insertBefore(container, loader)
return container
}
const appendMonths = (chunk) => { const appendMonths = (chunk) => {
const monthsFragment = chunk.querySelector("[data-chunk-months]") const monthsFragment = chunk.querySelector("[data-chunk-months]")
if (!monthsFragment) { if (!monthsFragment) return
return
} const container = ensureItemsContainer()
for (const monthNode of Array.from(monthsFragment.children)) {
const anchor = monthNode.id for (const node of Array.from(monthsFragment.children)) {
if (!anchor) { // Skip duplicate month headings (same month spanning a page boundary)
if (node.hasAttribute("data-timeline-month") && document.getElementById(node.id)) {
continue continue
} }
const existing = document.getElementById(anchor) container.appendChild(node)
if (existing) {
// Merge new events into existing month
const existingList = existing.querySelector("ol")
const newList = monthNode.querySelector("ol")
if (existingList && newList) {
// CSS :nth-of-type handles alternating pattern automatically
const newItems = Array.from(newList.querySelectorAll(".timeline-item"))
existingList.append(...newItems)
}
} else {
monthsContainer.insertBefore(monthNode, loader)
}
} }
} }
@ -188,6 +195,8 @@
) )
// Use MutationObserver to watch for new month headings being added // Use MutationObserver to watch for new month headings being added
const timelineItems = document.getElementById("timeline-items")
if (timelineItems) {
const mutationObserver = new MutationObserver((mutations) => { const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => { mutation.addedNodes.forEach((node) => {
@ -200,10 +209,7 @@
}) })
}) })
}) })
mutationObserver.observe(timelineItems, { childList: true })
const timelineContainer = document.getElementById("timeline-events")
if (timelineContainer) {
mutationObserver.observe(timelineContainer, { childList: true })
} }
// Observe existing headings // Observe existing headings