From 4ed1544d665fa61681339748043ff0ff678faa8d Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Feb 2026 09:51:48 +0000 Subject: [PATCH] fix(timeline): ensure cards always alternate sides across month boundaries Per-month
    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. --- templates/partials/timeline_month.html | 206 ++++++++++++------------- templates/styles.css | 10 +- templates/timeline.html | 80 +++++----- 3 files changed, 149 insertions(+), 147 deletions(-) diff --git a/templates/partials/timeline_month.html b/templates/partials/timeline_month.html index 7f1e13c..cd4dde5 100644 --- a/templates/partials/timeline_month.html +++ b/templates/partials/timeline_month.html @@ -7,109 +7,105 @@ -
      - {# Central timeline line #} - - {% for event in month.events %} -
    1. - {# Timeline node/bullet #} - -
      -
      - {{ event.kind_label }} - -
      -

      - {{ event.title }} - {% if let Some(url) = event.external_link %} - - - Open external link - - {% endif %} - {% if is_authenticated %} - {% if let Some(brew) = event.brew_data %} -
      - - - - - - - - -
      - {% endif %} - {% endif %} -

      - {% if event.details.len() > 0 %} -
      - {% for detail in event.details %} -
      -
      {{ detail.label }}
      -
      {{ detail.value }}
      -
      - {% endfor %} -
      - {% endif %} {% if let Some(notes) = event.tasting_notes %} {% if notes.is_empty() %} -

      No tasting notes yet.

      - {% else %} -
        - {% for note in notes %} -
      • - {{ note }} -
      • - {% endfor %} -
      - {% endif %} {% endif %} -
      -
    2. - {% endfor %} -
    +{% for event in month.events %} +
    + {# Timeline node/bullet #} + +
    +
    + {{ event.kind_label }} + +
    +

    + {{ event.title }} + {% if let Some(url) = event.external_link %} + + + Open external link + + {% endif %} + {% if is_authenticated %} + {% if let Some(brew) = event.brew_data %} +
    + + + + + + + + +
    + {% endif %} + {% endif %} +

    + {% if event.details.len() > 0 %} +
    + {% for detail in event.details %} +
    +
    {{ detail.label }}
    +
    {{ detail.value }}
    +
    + {% endfor %} +
    + {% endif %} {% if let Some(notes) = event.tasting_notes %} {% if notes.is_empty() %} +

    No tasting notes yet.

    + {% else %} +
      + {% for note in notes %} +
    • + {{ note }} +
    • + {% endfor %} +
    + {% endif %} {% endif %} +
    +
    +{% endfor %} diff --git a/templates/styles.css b/templates/styles.css index 3ff4e7d..1462f2a 100644 --- a/templates/styles.css +++ b/templates/styles.css @@ -118,24 +118,24 @@ a { padding-right: 0; } - /* Left side items (odd) - uses nth-of-type to skip the timeline-line div */ - .timeline-item:nth-of-type(odd) { + /* Left side items (odd) - counts only .timeline-item children, skipping month headings */ + .timeline-item:nth-child(odd of .timeline-item) { align-self: flex-start; padding-right: 2rem; } - .timeline-item:nth-of-type(odd) .timeline-node { + .timeline-item:nth-child(odd of .timeline-item) .timeline-node { right: -10px; left: auto; } /* Right side items (even) */ - .timeline-item:nth-of-type(even) { + .timeline-item:nth-child(even of .timeline-item) { align-self: flex-end; padding-left: 2rem; } - .timeline-item:nth-of-type(even) .timeline-node { + .timeline-item:nth-child(even of .timeline-item) .timeline-node { left: -10px; } diff --git a/templates/timeline.html b/templates/timeline.html index 408055e..365c2a2 100644 --- a/templates/timeline.html +++ b/templates/timeline.html @@ -8,9 +8,7 @@
    {% if months.is_empty() %} @@ -20,7 +18,12 @@ > No events yet.

    - {% else %} {% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %} + {% else %} +
    + {# Single central timeline line for all months #} + + {% for month in months %} {% include "partials/timeline_month.html" %} {% endfor %} +
    {% endif %}
    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 statusLine = document.getElementById("timeline-status") const endLine = document.getElementById("timeline-end") const errorLine = document.getElementById("timeline-error") const sentinel = document.getElementById("timeline-sentinel") 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 hasMorePages = loader.dataset.hasMore === "true" 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 monthsFragment = chunk.querySelector("[data-chunk-months]") - if (!monthsFragment) { - return - } - for (const monthNode of Array.from(monthsFragment.children)) { - const anchor = monthNode.id - if (!anchor) { + if (!monthsFragment) return + + const container = ensureItemsContainer() + + for (const node of Array.from(monthsFragment.children)) { + // Skip duplicate month headings (same month spanning a page boundary) + if (node.hasAttribute("data-timeline-month") && document.getElementById(node.id)) { continue } - const existing = document.getElementById(anchor) - 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) - } + container.appendChild(node) } } @@ -188,22 +195,21 @@ ) // Use MutationObserver to watch for new month headings being added - const mutationObserver = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - mutation.addedNodes.forEach((node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - const heading = node.querySelector?.(".timeline-heading") - if (heading) { - stickyObserver.observe(heading) + const timelineItems = document.getElementById("timeline-items") + if (timelineItems) { + const mutationObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + const heading = node.querySelector?.(".timeline-heading") + if (heading) { + stickyObserver.observe(heading) + } } - } + }) }) }) - }) - - const timelineContainer = document.getElementById("timeline-events") - if (timelineContainer) { - mutationObserver.observe(timelineContainer, { childList: true }) + mutationObserver.observe(timelineItems, { childList: true }) } // Observe existing headings