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/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..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 @@ -1,6 +1,6 @@ {% for record in other_records %} {% ifchanged record.date_creation|date:"Y-m" %} -
      2. +
      3. {{ record.date_creation|date:"M Y" }}
      4. {% endifchanged %}
      5. @@ -22,8 +22,8 @@
      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..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 @@ -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. @@ -13,8 +13,8 @@
      11. diff --git a/src/ahc/apps/animals/tests.py b/src/ahc/apps/animals/tests.py index 37c38a0..490a4bf 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,285 @@ 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_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 + 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..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,6 +64,31 @@ 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, 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 or not before_pk_param: + return [], False + 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 + 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: @@ -70,30 +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") - 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, 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"), @@ -126,24 +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") - 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, before_pk_param) available_months = list( other_history_for(animal).datetimes( @@ -159,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, diff --git a/static/css/timeline.css b/static/css/timeline.css index 9f57006..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,31 +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; -} - -@media (max-width: 640px) { - .timeline { - grid-template-columns: 1fr; - white-space: normal; - } + 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 { @@ -84,146 +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; - padding: 250px 0; - transition: all 1s; - overflow-x: scroll; - 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; -} - -.timeline ol li:last-child { - width: 340px; + margin: 0 0 1.5rem; } -.timeline ol li:not(:first-child) { - margin-left: 14px; +/* 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(: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; +.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 ea10610..2b25586 100644 --- a/static/js/timeline.js +++ b/static/js/timeline.js @@ -1,24 +1,65 @@ -// Timeline layout: equalise heights of list-item divs so the connector line aligns. -// 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 elements = document.querySelectorAll(".timeline li > div"); - if (elements.length > 0) { - setEqualHeights(elements); + 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) { + return; + } + if (!isDesktop) { + resetMobileLayout(ol, cards); + return; + } + if (cards.length === 0) { + return; + } + const maxHeight = setEqualHeights(cards); + setAxisPadding(ol, maxHeight); + }); +} + +// 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(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); +window.matchMedia(TIMELINE_DESKTOP_QUERY).addEventListener("change", initTimeline); diff --git a/static/js/timeline_jump.js b/static/js/timeline_jump.js index fd82568..40f9af8 100644 --- a/static/js/timeline_jump.js +++ b/static/js/timeline_jump.js @@ -15,14 +15,33 @@ 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) { - nodes[i].scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" }); + scrollToMonthNode(nodes[i]); return; } } } +// 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 && 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); + container.scrollTo({ left: target, behavior: "smooth" }); +} + window.addEventListener("load", initTimelineJump);