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,109 +7,105 @@
</svg>
</button>
</h2>
<ol class="timeline-list relative">
{# Central timeline line #}
<div class="timeline-line absolute top-0 bottom-0 w-0.5 bg-amber-200" aria-hidden="true"></div>
{% for event in month.events %}
<li class="timeline-item relative mb-8 last:mb-0" data-timeline-event>
{# Timeline node/bullet #}
<span
class="timeline-node absolute h-5 w-5 rounded-full border-4 border-amber-50 bg-amber-600"
aria-hidden="true"
></span>
<div class="rounded-lg border border-amber-200 bg-amber-50/80 p-5 shadow-sm text-left">
<div class="flex flex-wrap items-center justify-between gap-2">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800"
>{{ event.kind_label }}</span
>
<time
datetime="{{ event.iso_timestamp }}"
class="text-xs uppercase tracking-wide text-stone-500"
>
{{ event.date_label }}{% if let Some(label) = event.time_label %} · {{ label }}{% endif
%}
</time>
</div>
<h3 class="mt-3 flex items-center gap-2 text-lg font-semibold text-amber-800">
<a href="{{ event.link }}" class="hover:text-amber-600">{{ event.title }}</a>
{% if let Some(url) = event.external_link %}
<a
href="{{ url }}"
class="inline-flex h-7 w-7 items-center justify-center text-amber-700 transition hover:text-amber-500"
target="_blank"
rel="noreferrer noopener"
aria-label="Open external link"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
clip-rule="evenodd"
/>
<path
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
/>
</svg>
<span class="sr-only">Open external link</span>
</a>
{% endif %}
{% if is_authenticated %}
{% if let Some(brew) = event.brew_data %}
<form
class="inline ml-auto"
data-on:submit="@post('/api/v1/brews', {contentType: 'form'})"
>
<input type="hidden" name="bag_id" value="{{ brew.bag_id }}" />
<input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight }}" />
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting }}" />
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
<input type="hidden" name="water_volume" value="{{ brew.water_volume }}" />
<input type="hidden" name="water_temp" value="{{ brew.water_temp }}" />
<button
type="submit"
class="inline-flex h-7 w-7 items-center justify-center rounded-md text-amber-700 transition hover:text-amber-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500"
title="Brew again"
>
<span class="sr-only">Brew again</span>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0v2.43l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389 5.5 5.5 0 0 1 9.2-2.466l.312.311h-2.433a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.22Z"
clip-rule="evenodd"
/>
</svg>
</button>
</form>
{% endif %}
{% endif %}
</h3>
{% if event.details.len() > 0 %}
<dl class="mt-4 flex flex-col gap-2 text-sm text-stone-600">
{% for detail in event.details %}
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">{{ detail.label }}</dt>
<dd class="text-right">{{ detail.value }}</dd>
</div>
{% endfor %}
</dl>
{% endif %} {% if let Some(notes) = event.tasting_notes %} {% if notes.is_empty() %}
<p class="mt-4 text-sm text-stone-600">No tasting notes yet.</p>
{% else %}
<ul class="mt-4 flex flex-wrap gap-2">
{% for note in notes %}
<li>
<span
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
>{{ note }}</span
>
</li>
{% endfor %}
</ul>
{% endif %} {% endif %}
</div>
</li>
{% endfor %}
</ol>
</div>
{% for event in month.events %}
<div class="timeline-item relative mb-8" data-timeline-event>
{# Timeline node/bullet #}
<span
class="timeline-node absolute h-5 w-5 rounded-full border-4 border-amber-50 bg-amber-600"
aria-hidden="true"
></span>
<div class="rounded-lg border border-amber-200 bg-amber-50/80 p-5 shadow-sm text-left">
<div class="flex flex-wrap items-center justify-between gap-2">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold bg-amber-200 text-amber-800"
>{{ event.kind_label }}</span
>
<time
datetime="{{ event.iso_timestamp }}"
class="text-xs uppercase tracking-wide text-stone-500"
>
{{ event.date_label }}{% if let Some(label) = event.time_label %} · {{ label }}{% endif
%}
</time>
</div>
<h3 class="mt-3 flex items-center gap-2 text-lg font-semibold text-amber-800">
<a href="{{ event.link }}" class="hover:text-amber-600">{{ event.title }}</a>
{% if let Some(url) = event.external_link %}
<a
href="{{ url }}"
class="inline-flex h-7 w-7 items-center justify-center text-amber-700 transition hover:text-amber-500"
target="_blank"
rel="noreferrer noopener"
aria-label="Open external link"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M11.3 2a.7.7 0 0 0 0 1.4h3.3l-8.1 8.1a.7.7 0 1 0 1 1l8.1-8.1v3.3a.7.7 0 1 0 1.4 0V2.7A.7.7 0 0 0 16.3 2h-5Z"
clip-rule="evenodd"
/>
<path
d="M4.7 5.2a1.5 1.5 0 0 1 1.5-1.5h2.1a.7.7 0 1 0 0-1.4H6.2a2.9 2.9 0 0 0-2.9 2.9v7.6a2.9 2.9 0 0 0 2.9 2.9h7.6a2.9 2.9 0 0 0 2.9-2.9v-2.1a.7.7 0 1 0-1.4 0v2.1a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5V5.2Z"
/>
</svg>
<span class="sr-only">Open external link</span>
</a>
{% endif %}
{% if is_authenticated %}
{% if let Some(brew) = event.brew_data %}
<form
class="inline ml-auto"
data-on:submit="@post('/api/v1/brews', {contentType: 'form'})"
>
<input type="hidden" name="bag_id" value="{{ brew.bag_id }}" />
<input type="hidden" name="coffee_weight" value="{{ brew.coffee_weight }}" />
<input type="hidden" name="grinder_id" value="{{ brew.grinder_id }}" />
<input type="hidden" name="grind_setting" value="{{ brew.grind_setting }}" />
<input type="hidden" name="brewer_id" value="{{ brew.brewer_id }}" />
<input type="hidden" name="water_volume" value="{{ brew.water_volume }}" />
<input type="hidden" name="water_temp" value="{{ brew.water_temp }}" />
<button
type="submit"
class="inline-flex h-7 w-7 items-center justify-center rounded-md text-amber-700 transition hover:text-amber-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500"
title="Brew again"
>
<span class="sr-only">Brew again</span>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0v2.43l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389 5.5 5.5 0 0 1 9.2-2.466l.312.311h-2.433a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.22Z"
clip-rule="evenodd"
/>
</svg>
</button>
</form>
{% endif %}
{% endif %}
</h3>
{% if event.details.len() > 0 %}
<dl class="mt-4 flex flex-col gap-2 text-sm text-stone-600">
{% for detail in event.details %}
<div class="flex justify-between gap-2">
<dt class="font-medium text-stone-500">{{ detail.label }}</dt>
<dd class="text-right">{{ detail.value }}</dd>
</div>
{% endfor %}
</dl>
{% endif %} {% if let Some(notes) = event.tasting_notes %} {% if notes.is_empty() %}
<p class="mt-4 text-sm text-stone-600">No tasting notes yet.</p>
{% else %}
<ul class="mt-4 flex flex-wrap gap-2">
{% for note in notes %}
<li>
<span
class="inline-flex items-center rounded-full border border-amber-500/60 bg-amber-500/10 px-3 py-1 text-xs font-semibold text-amber-700"
>{{ note }}</span
>
</li>
{% endfor %}
</ul>
{% endif %} {% endif %}
</div>
</div>
{% endfor %}

View file

@ -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;
}

View file

@ -8,9 +8,7 @@
<div class="mt-8">
<section
class="space-y-12"
id="timeline-events"
data-role="timeline-months"
data-empty="{{ months.is_empty() }}"
>
{% if months.is_empty() %}
@ -20,7 +18,12 @@
>
No events yet.
</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 %}
<div
@ -49,15 +52,16 @@
<script type="module">
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