From 5475b9a80ef41091e3dc0a5c295724e54c0bf0eb Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:57:26 +0200 Subject: [PATCH 1/5] fix(ui): stabilize timeline layout --- static/css/timeline.css | 15 +++++++++-- static/js/timeline.js | 51 ++++++++++++++++++++++++++++---------- static/js/timeline_jump.js | 17 ++++++++++++- 3 files changed, 67 insertions(+), 16 deletions(-) diff --git a/static/css/timeline.css b/static/css/timeline.css index 9f57006..4f84d72 100644 --- a/static/css/timeline.css +++ b/static/css/timeline.css @@ -54,8 +54,9 @@ @media (max-width: 640px) { .timeline { + /* Stack .info above ol; keep nowrap (below) so the axis
  • s stay on one + line — the ol scrolls horizontally at any width, it must never wrap. */ grid-template-columns: 1fr; - white-space: normal; } } @@ -103,9 +104,18 @@ .timeline ol { font-size: 0; - padding: 250px 0; + /* Fallback for the brief window before timeline.js measures real card heights + (initTimeline() overrides this per instance via inline style — see there for + why a fixed value can't fit every card). Covers a typical short card only. */ + padding: 160px 0; transition: all 1s; overflow-x: scroll; + /* Cards are absolutely positioned above/below the axis (see nth-child rules + below), so setting overflow-x alone would leave overflow-y at its default + "auto", clipping any card the padding above doesn't cover with an + unstyled vertical scrollbar. initTimeline() sizes the padding to fit, so + vertical scrolling is never the intended way to reach a card. */ + overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-color: var(--timeline-yellow) var(--timeline-midnight-green); } @@ -146,6 +156,7 @@ position: absolute; left: calc(100% + 7px); width: 280px; + max-width: min(280px, calc(100vw - 2.5rem)); padding: 15px; font-size: 1rem; white-space: normal; diff --git a/static/js/timeline.js b/static/js/timeline.js index ea10610..50ccc32 100644 --- a/static/js/timeline.js +++ b/static/js/timeline.js @@ -1,24 +1,49 @@ -// Timeline layout: equalise heights of list-item divs so the connector line aligns. +// Timeline layout: equalise heights of list-item divs so the connector line aligns, +// and size the axis's vertical padding to match. Each .timeline instance is measured +// independently so unrelated timelines never force each other's card/axis size +// (e.g. the Notes tab renders a history timeline and a biometrics timeline together). // initTimeline() is called on window load and after htmx swaps. function initTimeline() { - const elements = document.querySelectorAll(".timeline li > div"); - if (elements.length > 0) { - setEqualHeights(elements); - } + document.querySelectorAll(".timeline").forEach(function (timeline) { + const ol = timeline.querySelector("ol"); + const cards = timeline.querySelectorAll("li > div"); + if (!ol || cards.length === 0) { + return; + } + const maxHeight = setEqualHeights(cards); + setAxisPadding(ol, maxHeight); + }); } -function setEqualHeights(el) { - let counter = 0; - for (let i = 0; i < el.length; i++) { - const singleHeight = el[i].offsetHeight; - if (counter < singleHeight) { - counter = singleHeight; +function setEqualHeights(elements) { + // Clear any height a previous run set, otherwise a card can never shrink + // back down after "Load older" or an htmx swap removes its taller siblings. + for (let i = 0; i < elements.length; i++) { + elements[i].style.height = ""; + } + let maxHeight = 0; + for (let i = 0; i < elements.length; i++) { + const singleHeight = elements[i].offsetHeight; + if (maxHeight < singleHeight) { + maxHeight = singleHeight; } } - for (let i = 0; i < el.length; i++) { - el[i].style.height = counter + "px"; + for (let i = 0; i < elements.length; i++) { + elements[i].style.height = maxHeight + "px"; } + return maxHeight; +} + +// Cards sit 16px above (odd) or below (even) the axis line via absolute +// positioning (see .timeline ol li:nth-child(odd/even) div in timeline.css), so +// the ol needs at least maxHeight + 16px of padding on each side, plus a little +// breathing room, to avoid clipping the tallest card. A single fixed padding +// can't fit every timeline's content, so it's computed per instance here. +function setAxisPadding(ol, maxHeight) { + const padding = maxHeight + 32; + ol.style.paddingTop = padding + "px"; + ol.style.paddingBottom = padding + "px"; } window.addEventListener("load", initTimeline); diff --git a/static/js/timeline_jump.js b/static/js/timeline_jump.js index fd82568..13a0c0a 100644 --- a/static/js/timeline_jump.js +++ b/static/js/timeline_jump.js @@ -19,10 +19,25 @@ function initTimelineJump() { var nodes = document.querySelectorAll("[id$='-" + month + "']"); for (var i = 0; i < nodes.length; i++) { if (nodes[i].id.indexOf("tlmonth-") === 0) { - nodes[i].scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" }); + scrollAxisToNode(nodes[i]); return; } } } +// Scroll the axis's own horizontal scroll container (the
      ) so the target month +// comes into view. scrollIntoView() would also drag the whole page vertically to +// satisfy the node's block-axis visibility, which is not wanted for a horizontal axis. +function scrollAxisToNode(node) { + var container = node.closest("ol"); + if (!container) { + node.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" }); + return; + } + var containerRect = container.getBoundingClientRect(); + var nodeRect = node.getBoundingClientRect(); + var target = container.scrollLeft + (nodeRect.left - containerRect.left); + container.scrollTo({ left: target, behavior: "smooth" }); +} + window.addEventListener("load", initTimelineJump); From 1fb083ee0d5fef6fa1d7018cf5114b1d38784a09 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:56:21 +0200 Subject: [PATCH 2/5] refactor(ui): consolidate timeline markup --- .../animals/templates/animals/tabs/_diet.html | 2 +- .../templates/animals/tabs/_medications.html | 2 +- .../templates/animals/tabs/_notes.html | 51 ++----------------- .../animals/templates/animals/tabs/_vet.html | 40 +-------------- .../tabs/partials/_timeline_month_jump.html | 16 ++++++ static/css/timeline.css | 4 ++ 6 files changed, 27 insertions(+), 88 deletions(-) create mode 100644 src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_month_jump.html diff --git a/src/ahc/apps/animals/templates/animals/tabs/_diet.html b/src/ahc/apps/animals/templates/animals/tabs/_diet.html index 80864a3..ded2383 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/_diet.html +++ b/src/ahc/apps/animals/templates/animals/tabs/_diet.html @@ -29,7 +29,7 @@

      E
      {% if diet_records %} -
      +
        {% for record in diet_records %}
      1. diff --git a/src/ahc/apps/animals/templates/animals/tabs/_medications.html b/src/ahc/apps/animals/templates/animals/tabs/_medications.html index d551277..0dd04e3 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/_medications.html +++ b/src/ahc/apps/animals/templates/animals/tabs/_medications.html @@ -17,7 +17,7 @@

        Medications


        {% if medication_records %} -
        +
          {% for record in medication_records %}
        1. diff --git a/src/ahc/apps/animals/templates/animals/tabs/_notes.html b/src/ahc/apps/animals/templates/animals/tabs/_notes.html index 40ece16..b1e1511 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/_notes.html +++ b/src/ahc/apps/animals/templates/animals/tabs/_notes.html @@ -13,58 +13,13 @@

          Notes

          hx-swap="innerHTML" data-modal-title="New note">Add a note View full timeline - {% if available_months %} - - {% endif %} + {% include "animals/tabs/partials/_timeline_month_jump.html" with tab_slug="notes" animal=animal available_months=available_months scroll_to_month=scroll_to_month only %} {% if other_records %}
            - {% for record in other_records %} - {% ifchanged record.date_creation|date:"Y-m" %} -
          1. - {% endifchanged %} -
          2. -
            - - {{ record.short_description }} - ({{ record.type_of_event }}) -
            - Edit - Delete -
            -
          3. - {% endfor %} - {% if tl_has_more %} -
          4. - -
          5. - {% endif %} + {% include "animals/tabs/partials/_timeline_nodes_notes.html" %}
          {% else %} @@ -76,7 +31,7 @@

          Notes

          {% if "biometrics" in allowed_categories %}

          Biometrics

          {% if biometric_records %} -
          +
            {% for record in biometric_records %}
          1. diff --git a/src/ahc/apps/animals/templates/animals/tabs/_vet.html b/src/ahc/apps/animals/templates/animals/tabs/_vet.html index 14c04c8..a71f104 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/_vet.html +++ b/src/ahc/apps/animals/templates/animals/tabs/_vet.html @@ -38,49 +38,13 @@

            Medical visit timeline

            hx-swap="innerHTML" data-modal-title="Add vet visit">Add vet visit View all visits - {% if available_months %} - - {% endif %} + {% include "animals/tabs/partials/_timeline_month_jump.html" with tab_slug="vet" animal=animal available_months=available_months scroll_to_month=scroll_to_month only %} {% if vet_records %}
              - {% for record in vet_records %} - {% ifchanged record.date_creation|date:"Y-m" %} -
            1. - {% endifchanged %} -
            2. -
              - - {{ record.short_description }} -
              -
            3. - {% endfor %} - {% if tl_has_more %} -
            4. - -
            5. - {% endif %} + {% include "animals/tabs/partials/_timeline_nodes_vet.html" %}
            {% else %} diff --git a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_month_jump.html b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_month_jump.html new file mode 100644 index 0000000..c0c633b --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_month_jump.html @@ -0,0 +1,16 @@ +{% if available_months %} + +{% endif %} diff --git a/static/css/timeline.css b/static/css/timeline.css index 4f84d72..0da4489 100644 --- a/static/css/timeline.css +++ b/static/css/timeline.css @@ -52,6 +52,10 @@ grid-gap: 20px; } +.timeline--single { + grid-template-columns: 1fr; +} + @media (max-width: 640px) { .timeline { /* Stack .info above ol; keep nowrap (below) so the axis
          2. s stay on one From 54914dc5baa40f5ce96ac90e0a96e516811116f4 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:49:04 +0200 Subject: [PATCH 3/5] refactor(ui): make timeline mobile-first --- .../tabs/partials/_timeline_nodes_notes.html | 2 +- .../tabs/partials/_timeline_nodes_vet.html | 2 +- static/css/timeline.css | 353 +++++++++++------- static/js/timeline.js | 30 +- static/js/timeline_jump.js | 20 +- 5 files changed, 261 insertions(+), 146 deletions(-) diff --git a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html index 5371d83..bbd36e8 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html +++ b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html @@ -1,6 +1,6 @@ {% for record in other_records %} {% ifchanged record.date_creation|date:"Y-m" %} -
          3. +
          4. {{ record.date_creation|date:"M Y" }}
          5. {% endifchanged %}
          6. diff --git a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html index b32a946..e666aa9 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html +++ b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html @@ -1,6 +1,6 @@ {% for record in vet_records %} {% ifchanged record.date_creation|date:"Y-m" %} -
          7. +
          8. {{ record.date_creation|date:"M Y" }}
          9. {% endifchanged %}
          10. diff --git a/static/css/timeline.css b/static/css/timeline.css index 0da4489..11d0c55 100644 --- a/static/css/timeline.css +++ b/static/css/timeline.css @@ -2,6 +2,8 @@ * * Note: Inter font previously imported via Google Fonts CDN (render-blocking). * Falls back to system-ui until the font is vendored locally. + * + * Mobile-first: base rules are a vertical feed; @media (min-width: 768px) below layers the historical horizontal axis on top, matching custom_pico.css's 767/768 tier. */ :root { @@ -11,6 +13,12 @@ --timeline-columbia-blue: #cee9e4; --timeline-midnight-green: #01565b; --timeline-yellow: #e5f33d; + + /* Axis line sits at --timeline-axis-offset from the
              edge; li content starts at --timeline-gutter — dots need both to land on the line (see .timeline ol li::before). */ + --timeline-axis-offset: 0.65rem; + --timeline-gutter: 1.9rem; + --timeline-dot-size: 14px; + --timeline-month-dot-size: 18px; } /* SECTION @@ -42,36 +50,17 @@ –––––––––––––––––––––––––––––––––––––––––––––––––– */ .timeline { - position: relative; - white-space: nowrap; max-width: 1400px; - padding: 0 10px; margin: 0 auto; - display: grid; - grid-template-columns: minmax(200px, 320px) auto; - grid-gap: 20px; -} - -.timeline--single { - grid-template-columns: 1fr; -} - -@media (max-width: 640px) { - .timeline { - /* Stack .info above ol; keep nowrap (below) so the axis
            1. s stay on one - line — the ol scrolls horizontally at any width, it must never wrap. */ - grid-template-columns: 1fr; - } + padding: 0; } .timeline .info { display: flex; flex-direction: column; - justify-content: center; - padding: 20px 40px; + gap: 0.5rem; + margin-bottom: 1.25rem; color: var(--timeline-white); - white-space: normal; - border-radius: 10px; } .timeline .info img { @@ -89,156 +78,262 @@ gap: 0.5rem; } -.timeline ol::-webkit-scrollbar { - height: 12px; +/* Controls area: month select and load-more button spacing */ +.records-actions select { + width: 100%; + margin-bottom: 0.25rem; + font-size: 0.875rem; } -.timeline ol::-webkit-scrollbar-thumb, -.timeline ol::-webkit-scrollbar-track { - border-radius: 92px; +/* Vertical feed:
                is normal block flow; the axis is pseudo-elements, not absolute-positioned cards, so no JS height measurement is needed here (see timeline.js). */ +.timeline ol { + position: relative; + list-style: none; + margin: 0; + padding-left: var(--timeline-gutter); } -.timeline ol::-webkit-scrollbar-thumb { +.timeline ol::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: var(--timeline-axis-offset); + width: 2px; background: var(--timeline-midnight-green); } -.timeline ol::-webkit-scrollbar-track { - background: var(--timeline-yellow); -} - -.timeline ol { - font-size: 0; - /* Fallback for the brief window before timeline.js measures real card heights - (initTimeline() overrides this per instance via inline style — see there for - why a fixed value can't fit every card). Covers a typical short card only. */ - padding: 160px 0; - transition: all 1s; - overflow-x: scroll; - /* Cards are absolutely positioned above/below the axis (see nth-child rules - below), so setting overflow-x alone would leave overflow-y at its default - "auto", clipping any card the padding above doesn't cover with an - unstyled vertical scrollbar. initTimeline() sizes the padding to fit, so - vertical scrolling is never the intended way to reach a card. */ - overflow-y: hidden; - scroll-snap-type: x mandatory; - scrollbar-color: var(--timeline-yellow) var(--timeline-midnight-green); -} - .timeline ol li { position: relative; - display: inline-block; - list-style-type: none; - width: 160px; - height: 5px; - background: var(--timeline-white); - scroll-snap-align: start; + margin: 0 0 1.5rem; } -.timeline ol li:last-child { - width: 340px; +/* The trailing empty
              1. every tab partial appends is a desktop-only spacer (see last-child width below); on mobile it'd render as a dangling axis dot. */ +.timeline ol li:last-child:empty { + display: none; } -.timeline ol li:not(:first-child) { - margin-left: 14px; -} - -.timeline ol li:not(:last-child)::after { +/* `left` is relative to the
              2. box (already shifted --timeline-gutter from the
                  edge), so it must subtract that gutter back out to land on the axis line. */ +.timeline ol li::before { content: ""; position: absolute; - top: 50%; - left: calc(100% + 1px); - bottom: 0; - width: 16px; - height: 16px; - transform: translateY(-50%); + top: 0.35em; + left: calc(var(--timeline-axis-offset) - var(--timeline-gutter) - (var(--timeline-dot-size) / 2)); + width: var(--timeline-dot-size); + height: var(--timeline-dot-size); border-radius: 50%; - background: var(--timeline-midnight-green); + background: var(--timeline-white); + border: 2px solid var(--timeline-midnight-green); z-index: 1; } -.timeline ol li div { - position: absolute; - left: calc(100% + 7px); - width: 280px; - max-width: min(280px, calc(100vw - 2.5rem)); +.timeline ol li > div { + box-sizing: border-box; + max-width: 100%; padding: 15px; - font-size: 1rem; - white-space: normal; + overflow-wrap: anywhere; color: var(--timeline-black); background: var(--timeline-white); - border-radius: 0 10px 10px 10px; -} - -.timeline ol li div::before { - content: ""; - position: absolute; - top: 100%; - left: 0; - width: 0; - height: 0; - border-style: solid; -} - -.timeline ol li:nth-child(odd) div { - top: -16px; - transform: translateY(-100%); - border-radius: 10px 10px 10px 0; -} - -.timeline ol li:nth-child(odd) div::before { - top: 100%; - border-width: 8px 8px 0 0; - border-color: var(--timeline-white) transparent transparent transparent; -} - -.timeline ol li:nth-child(even) div { - top: calc(100% + 16px); -} - -.timeline ol li:nth-child(even) div::before { - top: -8px; - border-width: 8px 0 0 8px; - border-color: transparent transparent transparent var(--timeline-white); + border-radius: 10px; } .timeline time { display: block; - font-size: 1.4rem; + font-size: 1.1rem; font-weight: bold; margin-bottom: 8px; color: var(--timeline-midnight-green); } -/* Month-start node: a wider segment with a month label above the line */ +/* Month marker: real text, not CSS-generated content, so it reaches the accessibility tree. */ .timeline ol li.month-start { - width: 200px; - background: var(--timeline-yellow); + margin: 2rem 0 1rem; + font-size: 0.8rem; + font-weight: bold; + color: var(--timeline-midnight-green); } .timeline ol li.month-start::before { - content: attr(data-month); - position: absolute; - bottom: calc(100% + 8px); - left: 0; - font-size: 0.75rem; - font-weight: bold; - color: var(--timeline-midnight-green); - white-space: nowrap; + width: var(--timeline-month-dot-size); + height: var(--timeline-month-dot-size); + left: calc(var(--timeline-axis-offset) - var(--timeline-gutter) - (var(--timeline-month-dot-size) / 2)); background: var(--timeline-yellow); - padding: 2px 6px; - border-radius: 4px; } -/* Controls area: month select and load-more button spacing */ -.records-actions select { - margin-bottom: 0.25rem; - font-size: 0.875rem; +.timeline ol li.month-start .month-start__label { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + background: var(--timeline-yellow); } .timeline-load-more div { display: flex; align-items: center; - justify-content: center; + justify-content: flex-start; +} + +/* Desktop enhancement: the historical horizontal axis; clamp()/min() widths replace the old fixed 160/200/280/340px sizes. */ +@media (min-width: 768px) { + .timeline { + display: grid; + grid-template-columns: minmax(200px, 320px) auto; + grid-gap: 20px; + padding: 0 10px; + } + + .timeline--single { + grid-template-columns: 1fr; + } + + .timeline .info { + justify-content: center; + padding: 20px 40px; + margin-bottom: 0; + white-space: normal; + border-radius: 10px; + } + + .records-actions select { + width: auto; + } + + .timeline ol { + font-size: 0; + white-space: nowrap; + /* Fallback until timeline.js measures real card heights and overrides this inline; covers a typical short card only. */ + padding: 160px 0; + transition: all 1s; + overflow-x: scroll; + /* Cards are absolutely positioned above/below the axis, so overflow-y must be hidden or a card taller than the padding gets an unstyled vertical scrollbar. */ + overflow-y: hidden; + scroll-snap-type: x mandatory; + scrollbar-color: var(--timeline-yellow) var(--timeline-midnight-green); + } + + .timeline ol::before { + display: none; + } + + .timeline ol::-webkit-scrollbar { + height: 12px; + } + + .timeline ol::-webkit-scrollbar-thumb, + .timeline ol::-webkit-scrollbar-track { + border-radius: 92px; + } + + .timeline ol::-webkit-scrollbar-thumb { + background: var(--timeline-midnight-green); + } + + .timeline ol::-webkit-scrollbar-track { + background: var(--timeline-yellow); + } + + .timeline ol li { + display: inline-block; + list-style-type: none; + width: clamp(120px, 14vw, 160px); + height: 5px; + margin: 0; + background: var(--timeline-white); + scroll-snap-align: start; + } + + .timeline ol li:last-child { + width: clamp(200px, 26vw, 340px); + } + + .timeline ol li:last-child:empty { + display: inline-block; + } + + .timeline ol li:not(:first-child) { + margin-left: 14px; + } + + .timeline ol li::before, + .timeline ol li.month-start::before { + content: none; + } + + .timeline ol li:not(:last-child)::after { + content: ""; + position: absolute; + top: 50%; + left: calc(100% + 1px); + bottom: 0; + width: 16px; + height: 16px; + transform: translateY(-50%); + border-radius: 50%; + background: var(--timeline-midnight-green); + z-index: 1; + } + + .timeline ol li > div { + position: absolute; + left: calc(100% + 7px); + width: clamp(200px, 22vw, 280px); + max-width: min(280px, calc(100vw - 2.5rem)); + font-size: 1rem; + white-space: normal; + border-radius: 0 10px 10px 10px; + } + + .timeline ol li > div::before { + content: ""; + position: absolute; + top: 100%; + left: 0; + width: 0; + height: 0; + border-style: solid; + } + + .timeline ol li:nth-child(odd) > div { + top: -16px; + transform: translateY(-100%); + border-radius: 10px 10px 10px 0; + } + + .timeline ol li:nth-child(odd) > div::before { + top: 100%; + border-width: 8px 8px 0 0; + border-color: var(--timeline-white) transparent transparent transparent; + } + + .timeline ol li:nth-child(even) > div { + top: calc(100% + 16px); + } + + .timeline ol li:nth-child(even) > div::before { + top: -8px; + border-width: 8px 0 0 8px; + border-color: transparent transparent transparent var(--timeline-white); + } + + /* Month-start node: a wider segment with a month label above the line */ + .timeline ol li.month-start { + width: clamp(160px, 18vw, 200px); + margin-top: 0; + margin-bottom: 0; + background: var(--timeline-yellow); + } + + .timeline ol li.month-start .month-start__label { + position: absolute; + bottom: calc(100% + 8px); + left: 0; + white-space: nowrap; + font-size: 0.75rem; + } + + .timeline-load-more div { + justify-content: center; + } } /* Full-page timeline: month heading anchors */ diff --git a/static/js/timeline.js b/static/js/timeline.js index 50ccc32..2b25586 100644 --- a/static/js/timeline.js +++ b/static/js/timeline.js @@ -1,14 +1,20 @@ -// Timeline layout: equalise heights of list-item divs so the connector line aligns, -// and size the axis's vertical padding to match. Each .timeline instance is measured -// independently so unrelated timelines never force each other's card/axis size -// (e.g. the Notes tab renders a history timeline and a biometrics timeline together). -// initTimeline() is called on window load and after htmx swaps. +// Timeline layout: on desktop, equalise card heights and size the axis's vertical padding to match; on mobile (plain vertical feed) any leftover inline styles from a prior desktop layout are cleared instead. Runs on load, after htmx swaps, and on matchMedia breakpoint changes (not resize). + +const TIMELINE_DESKTOP_QUERY = "(min-width: 768px)"; function initTimeline() { + const isDesktop = window.matchMedia(TIMELINE_DESKTOP_QUERY).matches; document.querySelectorAll(".timeline").forEach(function (timeline) { const ol = timeline.querySelector("ol"); const cards = timeline.querySelectorAll("li > div"); - if (!ol || cards.length === 0) { + if (!ol) { + return; + } + if (!isDesktop) { + resetMobileLayout(ol, cards); + return; + } + if (cards.length === 0) { return; } const maxHeight = setEqualHeights(cards); @@ -16,6 +22,15 @@ function initTimeline() { }); } +// Clears any inline height/padding a previous desktop layout left behind. +function resetMobileLayout(ol, cards) { + ol.style.paddingTop = ""; + ol.style.paddingBottom = ""; + for (let i = 0; i < cards.length; i++) { + cards[i].style.height = ""; + } +} + function setEqualHeights(elements) { // Clear any height a previous run set, otherwise a card can never shrink // back down after "Load older" or an htmx swap removes its taller siblings. @@ -36,7 +51,7 @@ function setEqualHeights(elements) { } // Cards sit 16px above (odd) or below (even) the axis line via absolute -// positioning (see .timeline ol li:nth-child(odd/even) div in timeline.css), so +// positioning (see .timeline ol li:nth-child(odd/even) > div in timeline.css), so // the ol needs at least maxHeight + 16px of padding on each side, plus a little // breathing room, to avoid clipping the tallest card. A single fixed padding // can't fit every timeline's content, so it's computed per instance here. @@ -47,3 +62,4 @@ function setAxisPadding(ol, maxHeight) { } window.addEventListener("load", initTimeline); +window.matchMedia(TIMELINE_DESKTOP_QUERY).addEventListener("change", initTimeline); diff --git a/static/js/timeline_jump.js b/static/js/timeline_jump.js index 13a0c0a..40f9af8 100644 --- a/static/js/timeline_jump.js +++ b/static/js/timeline_jump.js @@ -15,25 +15,29 @@ function initTimelineJump() { return; } - // Tab horizontal timelines: node anchors follow the pattern "tlmonth--YYYY-MM" + // Tab timelines: node anchors follow the pattern "tlmonth--YYYY-MM" var nodes = document.querySelectorAll("[id$='-" + month + "']"); for (var i = 0; i < nodes.length; i++) { if (nodes[i].id.indexOf("tlmonth-") === 0) { - scrollAxisToNode(nodes[i]); + scrollToMonthNode(nodes[i]); return; } } } -// Scroll the axis's own horizontal scroll container (the
                    ) so the target month -// comes into view. scrollIntoView() would also drag the whole page vertically to -// satisfy the node's block-axis visibility, which is not wanted for a horizontal axis. -function scrollAxisToNode(node) { +// Reads the container's actual overflow-x instead of re-checking the breakpoint here, so this stays correct even if timeline.css's breakpoint changes. +function scrollToMonthNode(node) { var container = node.closest("ol"); - if (!container) { - node.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" }); + if (container && getComputedStyle(container).overflowX === "scroll") { + scrollAxisHorizontally(container, node); return; } + // Vertical mobile axis: the node is in normal document flow, so scroll the page. + node.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +// Scrolls the ol's own horizontal container — scrollIntoView() would also drag the page vertically, which is wrong for a horizontal axis. +function scrollAxisHorizontally(container, node) { var containerRect = container.getBoundingClientRect(); var nodeRect = node.getBoundingClientRect(); var target = container.scrollLeft + (nodeRect.left - containerRect.left); From 698799c534fd06da23c38c0e96116d5f634015f2 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:01:41 +0200 Subject: [PATCH 4/5] fix(ui): fix timeline load-more pagination --- .../tabs/partials/_timeline_nodes_notes.html | 4 +- .../tabs/partials/_timeline_nodes_vet.html | 4 +- src/ahc/apps/animals/tests.py | 226 +++++++++++++++++- src/ahc/apps/animals/views.py | 47 ++-- 4 files changed, 250 insertions(+), 31 deletions(-) diff --git a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html index bbd36e8..33b1a1a 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html +++ b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html @@ -22,8 +22,8 @@
                  1. diff --git a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html index e666aa9..bf15ef9 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html +++ b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html @@ -13,8 +13,8 @@
                  2. diff --git a/src/ahc/apps/animals/tests.py b/src/ahc/apps/animals/tests.py index 37c38a0..c6df6f6 100644 --- a/src/ahc/apps/animals/tests.py +++ b/src/ahc/apps/animals/tests.py @@ -1,7 +1,11 @@ -from datetime import date +import html +import re +from datetime import date, timedelta from unittest.mock import MagicMock, patch import pytest +from django.urls import reverse +from django.utils import timezone from ahc.apps.animals.models import Animal from ahc.apps.animals.selectors import ( @@ -473,6 +477,226 @@ def test_unknown_slug_returns_404(self, animal, user_profile): assert response.status_code == 404 +@pytest.mark.integration +@pytest.mark.django_db +class TestTimelineLoadMorePagination: + """Regression for the "+HH:MM" cursor offset decoded as a space, making parse_datetime() return None.""" + + @pytest.fixture + def animal(self, db, user_profile): + _, profile = user_profile + return Animal.objects.create(full_name="Milo", owner=profile) + + def _client_for(self, user): + from django.test import Client + + c = Client() + c.force_login(user) + return c + + def _create_records(self, animal, profile, count, type_of_event): + """Create `count` MedicalRecords, newest-first, backdated via .update() (auto_now_add ignores explicit values).""" + from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord + + base = timezone.now() + created = [ + MedicalRecord.objects.create( + animal=animal, author=profile, short_description=f"{type_of_event} {i}", type_of_event=type_of_event + ) + for i in range(count) + ] + for i, record in enumerate(created): + MedicalRecord.objects.filter(pk=record.pk).update(date_creation=base - timedelta(hours=i)) + return list(MedicalRecord.objects.filter(pk__in=[r.pk for r in created]).order_by("-date_creation")) + + def _create_notes(self, animal, profile, count): + return self._create_records(animal, profile, count, "fast_note") + + def _create_vet_visits(self, animal, profile, count): + return self._create_records(animal, profile, count, "medical_visit") + + def _present_pks(self, content, records): + return {r.pk for r in records if reverse("note_edit", kwargs={"pk": r.pk}) in content} + + def _has_load_more(self, content, node_id): + return f'id="{node_id}"' in content + + def _extract_load_more_href(self, content, node_id): + """Extract the rendered hx-get URL, HTML-unescaped like a browser, for replaying the same encoding path.""" + match = re.search(rf'id="{node_id}"[\s\S]*?hx-get="([^"]+)"', content) + assert match, f"expected a Load older node with id={node_id!r} in response" + return html.unescape(match.group(1)) + + def test_notes_20_records_renders_all_without_load_more(self, animal, user_profile): + user, profile = user_profile + records = self._create_notes(animal, profile, 20) + c = self._client_for(user) + + response = c.get(f"/pet/{animal.id}/tab/notes/", HTTP_HX_REQUEST="true") + content = response.content.decode() + + assert self._present_pks(content, records) == {r.pk for r in records} + assert not self._has_load_more(content, "timeline-more-notes") + + def test_notes_21_records_second_page_has_exactly_one_new_record(self, animal, user_profile): + user, profile = user_profile + records = self._create_notes(animal, profile, 21) + c = self._client_for(user) + + first_content = c.get(f"/pet/{animal.id}/tab/notes/", HTTP_HX_REQUEST="true").content.decode() + first_present = self._present_pks(first_content, records) + assert len(first_present) == 20 + assert self._has_load_more(first_content, "timeline-more-notes") + + href = self._extract_load_more_href(first_content, "timeline-more-notes") + second_content = c.get(href, HTTP_HX_REQUEST="true").content.decode() + second_present = self._present_pks(second_content, records) + + assert len(second_present) == 1 + assert first_present.isdisjoint(second_present) + assert first_present | second_present == {r.pk for r in records} + assert not self._has_load_more(second_content, "timeline-more-notes") + + def test_notes_24_records_second_page_has_remaining_four_no_duplicates(self, animal, user_profile): + user, profile = user_profile + records = self._create_notes(animal, profile, 24) + c = self._client_for(user) + + first_content = c.get(f"/pet/{animal.id}/tab/notes/", HTTP_HX_REQUEST="true").content.decode() + first_present = self._present_pks(first_content, records) + assert len(first_present) == 20 + assert self._has_load_more(first_content, "timeline-more-notes") + + href = self._extract_load_more_href(first_content, "timeline-more-notes") + second_content = c.get(href, HTTP_HX_REQUEST="true").content.decode() + second_present = self._present_pks(second_content, records) + + assert len(second_present) == 4 + assert first_present.isdisjoint(second_present) + assert first_present | second_present == {r.pk for r in records} + assert not self._has_load_more(second_content, "timeline-more-notes") + + def test_notes_40_records_two_full_pages_no_load_more_after(self, animal, user_profile): + user, profile = user_profile + records = self._create_notes(animal, profile, 40) + c = self._client_for(user) + + first_content = c.get(f"/pet/{animal.id}/tab/notes/", HTTP_HX_REQUEST="true").content.decode() + first_present = self._present_pks(first_content, records) + assert len(first_present) == 20 + assert self._has_load_more(first_content, "timeline-more-notes") + + href = self._extract_load_more_href(first_content, "timeline-more-notes") + second_content = c.get(href, HTTP_HX_REQUEST="true").content.decode() + second_present = self._present_pks(second_content, records) + + assert len(second_present) == 20 + assert first_present.isdisjoint(second_present) + assert first_present | second_present == {r.pk for r in records} + assert not self._has_load_more(second_content, "timeline-more-notes") + + def test_notes_41_records_three_pages_each_record_appears_once(self, animal, user_profile): + user, profile = user_profile + records = self._create_notes(animal, profile, 41) + c = self._client_for(user) + + first_content = c.get(f"/pet/{animal.id}/tab/notes/", HTTP_HX_REQUEST="true").content.decode() + first_present = self._present_pks(first_content, records) + assert len(first_present) == 20 + assert self._has_load_more(first_content, "timeline-more-notes") + + href = self._extract_load_more_href(first_content, "timeline-more-notes") + second_content = c.get(href, HTTP_HX_REQUEST="true").content.decode() + second_present = self._present_pks(second_content, records) + assert len(second_present) == 20 + assert self._has_load_more(second_content, "timeline-more-notes") + + href2 = self._extract_load_more_href(second_content, "timeline-more-notes") + third_content = c.get(href2, HTTP_HX_REQUEST="true").content.decode() + third_present = self._present_pks(third_content, records) + assert len(third_present) == 1 + assert not self._has_load_more(third_content, "timeline-more-notes") + + assert first_present.isdisjoint(second_present) + assert first_present.isdisjoint(third_present) + assert second_present.isdisjoint(third_present) + assert first_present | second_present | third_present == {r.pk for r in records} + + def test_vet_24_records_second_page_has_remaining_four_no_duplicates(self, animal, user_profile): + user, profile = user_profile + records = self._create_vet_visits(animal, profile, 24) + c = self._client_for(user) + + first_content = c.get(f"/pet/{animal.id}/tab/vet/", HTTP_HX_REQUEST="true").content.decode() + first_present = self._present_pks(first_content, records) + assert len(first_present) == 20 + assert self._has_load_more(first_content, "timeline-more-vet") + + href = self._extract_load_more_href(first_content, "timeline-more-vet") + second_content = c.get(href, HTTP_HX_REQUEST="true").content.decode() + second_present = self._present_pks(second_content, records) + + assert len(second_present) == 4 + assert first_present.isdisjoint(second_present) + assert first_present | second_present == {r.pk for r in records} + assert not self._has_load_more(second_content, "timeline-more-vet") + + def test_notes_month_jump_then_load_older_continues_without_duplicates(self, animal, user_profile): + user, profile = user_profile + records = self._create_notes(animal, profile, 25) + month_param = records[0].date_creation.strftime("%Y-%m") + c = self._client_for(user) + + first_response = c.get(f"/pet/{animal.id}/tab/notes/?month={month_param}", HTTP_HX_REQUEST="true") + assert first_response.status_code == 200 + first_content = first_response.content.decode() + first_present = self._present_pks(first_content, records) + assert len(first_present) == 20 + assert self._has_load_more(first_content, "timeline-more-notes") + + href = self._extract_load_more_href(first_content, "timeline-more-notes") + assert "month=" not in href, "Load older must continue from the cursor, not re-target the whole month" + + second_content = c.get(href, HTTP_HX_REQUEST="true").content.decode() + second_present = self._present_pks(second_content, records) + + assert len(second_present) == 5 + assert first_present.isdisjoint(second_present) + assert first_present | second_present == {r.pk for r in records} + assert not self._has_load_more(second_content, "timeline-more-notes") + + def test_notes_malformed_cursor_fails_closed(self, animal, user_profile): + user, profile = user_profile + records = self._create_notes(animal, profile, 24) + c = self._client_for(user) + + response = c.get(f"/pet/{animal.id}/tab/notes/?before=not-a-datetime&load_more=1", HTTP_HX_REQUEST="true") + content = response.content.decode() + + assert response.status_code == 200 + assert self._present_pks(content, records) == set() + assert not self._has_load_more(content, "timeline-more-notes") + + def test_notes_load_more_href_survives_url_round_trip_with_tz_offset_cursor(self, animal, user_profile): + """Cursor is always UTC ("+00:00"); href must carry a percent-encoded '+' or this test passes vacuously.""" + user, profile = user_profile + records = self._create_notes(animal, profile, 21) + c = self._client_for(user) + + first_content = c.get(f"/pet/{animal.id}/tab/notes/", HTTP_HX_REQUEST="true").content.decode() + href = self._extract_load_more_href(first_content, "timeline-more-notes") + cursor_value = href.split("before=")[1].split("&")[0] + + assert "%2B" in cursor_value, f"expected a percent-encoded '+' UTC offset in the cursor, got {cursor_value!r}" + assert "+" not in cursor_value, "a literal '+' would be decoded as a space by the server" + + second_content = c.get(href, HTTP_HX_REQUEST="true").content.decode() + second_present = self._present_pks(second_content, records) + + assert len(second_present) == 1 + assert not self._has_load_more(second_content, "timeline-more-notes") + + @pytest.mark.integration @pytest.mark.django_db class TestRemoveKeeperView: diff --git a/src/ahc/apps/animals/views.py b/src/ahc/apps/animals/views.py index 93d4056..c07b9ea 100644 --- a/src/ahc/apps/animals/views.py +++ b/src/ahc/apps/animals/views.py @@ -62,6 +62,25 @@ def _timeline_boundary_from_month(month_param: str) -> datetime | None: return timezone.make_aware(datetime(first_of_next.year, first_of_next.month, first_of_next.day, 0, 0, 0), tz) +def _resolve_timeline_page(qs, month_param: str | None, before_param: str | None) -> tuple[list, bool]: + """Apply month-jump/cursor filtering and slice one page; an unparsable before_param fails closed (no records).""" + if month_param and not before_param: + boundary = _timeline_boundary_from_month(month_param) + if boundary: + qs = qs.filter(date_creation__lt=boundary) + elif before_param: + before_dt = parse_datetime(before_param) + if before_dt is None: + return [], False + qs = qs.filter(date_creation__lt=before_dt) + + records = list(qs[: _TIMELINE_PER_PAGE + 1]) + tl_has_more = len(records) > _TIMELINE_PER_PAGE + if tl_has_more: + records = records[:_TIMELINE_PER_PAGE] + return records, tl_has_more + + def _build_vet(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]: ctx: dict[str, Any] = {} if allowed is None or "vet_contact" in allowed: @@ -75,19 +94,7 @@ def _build_vet(request, animal: Animal, allowed: set[str] | None = None) -> dict month_param = request.GET.get("month") before_param = request.GET.get("before") - if month_param and not before_param: - boundary = _timeline_boundary_from_month(month_param) - if boundary: - qs = qs.filter(date_creation__lt=boundary) - elif before_param: - before_dt = parse_datetime(before_param) - if before_dt: - qs = qs.filter(date_creation__lt=before_dt) - - records = list(qs[: _TIMELINE_PER_PAGE + 1]) - tl_has_more = len(records) > _TIMELINE_PER_PAGE - if tl_has_more: - records = records[:_TIMELINE_PER_PAGE] + records, tl_has_more = _resolve_timeline_page(qs, month_param, before_param) ctx.update( { @@ -131,19 +138,7 @@ def _build_notes(request, animal: Animal, allowed: set[str] | None = None) -> di month_param = request.GET.get("month") before_param = request.GET.get("before") - if month_param and not before_param: - boundary = _timeline_boundary_from_month(month_param) - if boundary: - qs = qs.filter(date_creation__lt=boundary) - elif before_param: - before_dt = parse_datetime(before_param) - if before_dt: - qs = qs.filter(date_creation__lt=before_dt) - - records = list(qs[: _TIMELINE_PER_PAGE + 1]) - tl_has_more = len(records) > _TIMELINE_PER_PAGE - if tl_has_more: - records = records[:_TIMELINE_PER_PAGE] + records, tl_has_more = _resolve_timeline_page(qs, month_param, before_param) available_months = list( other_history_for(animal).datetimes( From 85e89f04376dfb21915b56b278bae502c0534d18 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:54:15 +0200 Subject: [PATCH 5/5] fix(ui): fix timeline pagination for tied date_creation timestamps --- .../tabs/partials/_timeline_nodes_notes.html | 4 +- .../tabs/partials/_timeline_nodes_vet.html | 4 +- src/ahc/apps/animals/tests.py | 59 +++++++++++++++++++ src/ahc/apps/animals/views.py | 28 ++++++--- 4 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html index 33b1a1a..41f686b 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html +++ b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html @@ -22,8 +22,8 @@
                  3. diff --git a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html index bf15ef9..0649a62 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html +++ b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html @@ -13,8 +13,8 @@
                  4. diff --git a/src/ahc/apps/animals/tests.py b/src/ahc/apps/animals/tests.py index c6df6f6..490a4bf 100644 --- a/src/ahc/apps/animals/tests.py +++ b/src/ahc/apps/animals/tests.py @@ -677,6 +677,65 @@ def test_notes_malformed_cursor_fails_closed(self, animal, user_profile): assert self._present_pks(content, records) == set() assert not self._has_load_more(content, "timeline-more-notes") + def test_notes_before_without_before_id_fails_closed(self, animal, user_profile): + user, profile = user_profile + records = self._create_notes(animal, profile, 24) + c = self._client_for(user) + + first_content = c.get(f"/pet/{animal.id}/tab/notes/", HTTP_HX_REQUEST="true").content.decode() + href = self._extract_load_more_href(first_content, "timeline-more-notes") + before_only = href.split("&before_id=")[0] + + response = c.get(f"{before_only}&load_more=1", HTTP_HX_REQUEST="true") + content = response.content.decode() + + assert response.status_code == 200 + assert self._present_pks(content, records) == set() + assert not self._has_load_more(content, "timeline-more-notes") + + def test_notes_tied_date_creation_at_page_boundary_drops_no_records(self, animal, user_profile): + from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord + + user, profile = user_profile + base = timezone.now() + + unique = [ + MedicalRecord.objects.create( + animal=animal, author=profile, short_description=f"unique {i}", type_of_event="fast_note" + ) + for i in range(19) + ] + for i, r in enumerate(unique): + MedicalRecord.objects.filter(pk=r.pk).update(date_creation=base - timedelta(hours=i + 1)) + + # 5 records sharing one timestamp, straddling the page boundary (1 of them fills page one). + tied_ts = base - timedelta(hours=20) + tied = [ + MedicalRecord.objects.create( + animal=animal, author=profile, short_description=f"tied {i}", type_of_event="fast_note" + ) + for i in range(5) + ] + for r in tied: + MedicalRecord.objects.filter(pk=r.pk).update(date_creation=tied_ts) + + records = list(MedicalRecord.objects.filter(pk__in=[r.pk for r in unique + tied])) + c = self._client_for(user) + + first_content = c.get(f"/pet/{animal.id}/tab/notes/", HTTP_HX_REQUEST="true").content.decode() + first_present = self._present_pks(first_content, records) + assert len(first_present) == 20 + assert self._has_load_more(first_content, "timeline-more-notes") + + href = self._extract_load_more_href(first_content, "timeline-more-notes") + second_content = c.get(href, HTTP_HX_REQUEST="true").content.decode() + second_present = self._present_pks(second_content, records) + + assert len(second_present) == 4 + assert first_present.isdisjoint(second_present) + assert first_present | second_present == {r.pk for r in records} + assert not self._has_load_more(second_content, "timeline-more-notes") + def test_notes_load_more_href_survives_url_round_trip_with_tz_offset_cursor(self, animal, user_profile): """Cursor is always UTC ("+00:00"); href must carry a percent-encoded '+' or this test passes vacuously.""" user, profile = user_profile diff --git a/src/ahc/apps/animals/views.py b/src/ahc/apps/animals/views.py index c07b9ea..d74d4df 100644 --- a/src/ahc/apps/animals/views.py +++ b/src/ahc/apps/animals/views.py @@ -1,11 +1,13 @@ from __future__ import annotations +import uuid from collections.abc import Callable from dataclasses import dataclass from datetime import date, datetime from typing import TYPE_CHECKING, Any from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.db.models import Q from django.http import Http404, JsonResponse from django.urls import reverse from django.utils import timezone @@ -62,17 +64,23 @@ def _timeline_boundary_from_month(month_param: str) -> datetime | None: return timezone.make_aware(datetime(first_of_next.year, first_of_next.month, first_of_next.day, 0, 0, 0), tz) -def _resolve_timeline_page(qs, month_param: str | None, before_param: str | None) -> tuple[list, bool]: - """Apply month-jump/cursor filtering and slice one page; an unparsable before_param fails closed (no records).""" +def _resolve_timeline_page( + qs, month_param: str | None, before_param: str | None, before_pk_param: str | None +) -> tuple[list, bool]: + """Filter/slice one page (qs ordered -date_creation, -pk); (date, pk) cursor avoids skipping ties at the boundary.""" if month_param and not before_param: boundary = _timeline_boundary_from_month(month_param) if boundary: qs = qs.filter(date_creation__lt=boundary) elif before_param: before_dt = parse_datetime(before_param) - if before_dt is None: + if before_dt is None or not before_pk_param: return [], False - qs = qs.filter(date_creation__lt=before_dt) + try: + before_pk = uuid.UUID(before_pk_param) + except ValueError: + return [], False + qs = qs.filter(Q(date_creation__lt=before_dt) | Q(date_creation=before_dt, pk__lt=before_pk)) records = list(qs[: _TIMELINE_PER_PAGE + 1]) tl_has_more = len(records) > _TIMELINE_PER_PAGE @@ -89,18 +97,20 @@ def _build_vet(request, animal: Animal, allowed: set[str] | None = None) -> dict if allowed is None or "history" in allowed: from ahc.apps.medical_notes.selectors import available_months_for, timeline_for - qs = timeline_for(animal, type_of_event="medical_visit").order_by("-date_creation") + qs = timeline_for(animal, type_of_event="medical_visit").order_by("-date_creation", "-pk") month_param = request.GET.get("month") before_param = request.GET.get("before") + before_pk_param = request.GET.get("before_id") - records, tl_has_more = _resolve_timeline_page(qs, month_param, before_param) + records, tl_has_more = _resolve_timeline_page(qs, month_param, before_param, before_pk_param) ctx.update( { "vet_records": records, "tl_has_more": tl_has_more, "tl_next_before": records[-1].date_creation.isoformat() if records else None, + "tl_next_before_pk": records[-1].pk if records else None, "tl_slug": "vet", "scroll_to_month": month_param or "", "available_months": available_months_for(animal, type_of_event="medical_visit"), @@ -133,12 +143,13 @@ def _build_notes(request, animal: Animal, allowed: set[str] | None = None) -> di if allowed is None or "history" in allowed: from ahc.apps.medical_notes.selectors import other_history_for - qs = other_history_for(animal) + qs = other_history_for(animal).order_by("-date_creation", "-pk") month_param = request.GET.get("month") before_param = request.GET.get("before") + before_pk_param = request.GET.get("before_id") - records, tl_has_more = _resolve_timeline_page(qs, month_param, before_param) + records, tl_has_more = _resolve_timeline_page(qs, month_param, before_param, before_pk_param) available_months = list( other_history_for(animal).datetimes( @@ -154,6 +165,7 @@ def _build_notes(request, animal: Animal, allowed: set[str] | None = None) -> di "other_records": records, "tl_has_more": tl_has_more, "tl_next_before": records[-1].date_creation.isoformat() if records else None, + "tl_next_before_pk": records[-1].pk if records else None, "tl_slug": "notes", "scroll_to_month": month_param or "", "available_months": available_months,