From b8ed1d5d65928d88710ba3fc56bbc66ab5cd8fc9 Mon Sep 17 00:00:00 2001
From: Edward Betts <edward@4angle.com>
Date: Wed, 26 Jun 2024 08:48:58 +0100
Subject: [PATCH 1/6] Handle missing space launch

---
 update.py | 22 ++++++++++++++++------
 1 file changed, 16 insertions(+), 6 deletions(-)

diff --git a/update.py b/update.py
index 09a6d39..31d346e 100755
--- a/update.py
+++ b/update.py
@@ -99,10 +99,15 @@ Agenda: https://edwardbetts.com/agenda/
 
 
 def report_space_launch_change(
-    config: flask.config.Config, prev_launch: StrDict, cur_launch: StrDict
+    config: flask.config.Config, prev_launch: StrDict | None, cur_launch: StrDict | None
 ) -> None:
     """Send mail to announce change to space launch data."""
-    subject = f'Change to {cur_launch["name"]}'
+    if cur_launch:
+        name = cur_launch["name"]
+    else:
+        assert prev_launch
+        name = prev_launch["name"]
+    subject = f"Change to {name}"
 
     body = f"""
 A space launch of interest was updated.
@@ -119,9 +124,11 @@ New launch data
     send_mail(config, subject, body)
 
 
-def get_launch_by_slug(data: StrDict, slug: str) -> StrDict:
+def get_launch_by_slug(data: StrDict, slug: str) -> StrDict | None:
     """Find last update for space launch."""
-    return {item["slug"]: typing.cast(StrDict, item) for item in data["results"]}[slug]
+    return {item["slug"]: typing.cast(StrDict, item) for item in data["results"]}.get(
+        slug
+    )
 
 
 def update_thespacedevs(config: flask.config.Config) -> None:
@@ -142,8 +149,11 @@ def update_thespacedevs(config: flask.config.Config) -> None:
 
     for slug in config["FOLLOW_LAUNCHES"]:
         prev, cur = prev_launches[slug], cur_launches[slug]
-        if prev["last_updated"] != cur["last_updated"]:
-            report_space_launch_change(config, prev, cur)
+        if prev is None and cur is None:
+            continue
+        if prev and cur and prev["last_updated"] == cur["last_updated"]:
+            continue
+        report_space_launch_change(config, prev, cur)
 
     time_taken = time() - t0
     if not sys.stdin.isatty():

From 0e49d1872137f37f18a01c2c5bd35182586196ed Mon Sep 17 00:00:00 2001
From: Edward Betts <edward@4angle.com>
Date: Mon, 1 Jul 2024 18:50:08 +0100
Subject: [PATCH 2/6] Correct spelling mistake

---
 web_view.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/web_view.py b/web_view.py
index ca643c4..2cd1e80 100755
--- a/web_view.py
+++ b/web_view.py
@@ -35,7 +35,7 @@ agenda.error_mail.setup_error_mail(app)
 
 @app.before_request
 def handle_auth() -> None:
-    """Handle autentication and set global user."""
+    """Handle authentication and set global user."""
     flask.g.user = UniAuth.auth.get_current_user()
 
 

From 01b42845c3974da7b79b738f05399c34f87372c8 Mon Sep 17 00:00:00 2001
From: Edward Betts <edward@4angle.com>
Date: Mon, 1 Jul 2024 22:22:01 +0300
Subject: [PATCH 3/6] Move some functions into a utils module

---
 agenda/types.py | 42 +++++++++++-------------------------------
 agenda/utils.py | 23 +++++++++++++++++++++++
 2 files changed, 34 insertions(+), 31 deletions(-)
 create mode 100644 agenda/utils.py

diff --git a/agenda/types.py b/agenda/types.py
index 6d18909..a8f3a8f 100644
--- a/agenda/types.py
+++ b/agenda/types.py
@@ -12,28 +12,12 @@ from pycountry.db import Country
 import agenda
 from agenda import format_list_with_ampersand
 
+from . import utils
+
 StrDict = dict[str, typing.Any]
 DateOrDateTime = datetime.datetime | datetime.date
 
 
-def as_date(d: DateOrDateTime) -> datetime.date:
-    """Convert datetime to date."""
-    if isinstance(d, datetime.datetime):
-        return d.date()
-    assert isinstance(d, datetime.date)
-    return d
-
-
-def as_datetime(d: DateOrDateTime) -> datetime.datetime:
-    """Date/time of event."""
-    t0 = datetime.datetime.min.time()
-    return (
-        d
-        if isinstance(d, datetime.datetime)
-        else datetime.datetime.combine(d, t0).replace(tzinfo=datetime.timezone.utc)
-    )
-
-
 @dataclass
 class TripElement:
     """Trip element."""
@@ -106,18 +90,20 @@ class Trip:
     def end(self) -> datetime.date | None:
         """End date for trip."""
         max_conference_end = (
-            max(as_date(item["end"]) for item in self.conferences)
+            max(utils.as_date(item["end"]) for item in self.conferences)
             if self.conferences
             else datetime.date.min
         )
         assert isinstance(max_conference_end, datetime.date)
 
-        arrive = [as_date(item["arrive"]) for item in self.travel if "arrive" in item]
+        arrive = [
+            utils.as_date(item["arrive"]) for item in self.travel if "arrive" in item
+        ]
         travel_end = max(arrive) if arrive else datetime.date.min
         assert isinstance(travel_end, datetime.date)
 
         accommodation_end = (
-            max(as_date(item["to"]) for item in self.accommodation)
+            max(utils.as_date(item["to"]) for item in self.accommodation)
             if self.accommodation
             else datetime.date.min
         )
@@ -314,7 +300,7 @@ class Trip:
                     )
                 )
 
-        return sorted(elements, key=lambda e: as_datetime(e.start_time))
+        return sorted(elements, key=lambda e: utils.as_datetime(e.start_time))
 
     def elements_grouped_by_day(self) -> list[tuple[datetime.date, list[TripElement]]]:
         """Group trip elements by day."""
@@ -325,7 +311,7 @@ class Trip:
 
         for element in self.elements():
             # Extract the date part of the 'when' attribute
-            day = as_date(element.start_time)
+            day = utils.as_date(element.start_time)
             grouped_elements[day].append(element)
 
         # Sort elements within each day
@@ -334,7 +320,7 @@ class Trip:
                 key=lambda e: (
                     e.element_type == "check-in",  # check-out elements last
                     e.element_type != "check-out",  # check-in elements first
-                    as_datetime(e.start_time),  # then sort by time
+                    utils.as_datetime(e.start_time),  # then sort by time
                 )
             )
 
@@ -403,13 +389,7 @@ class Event:
     @property
     def as_datetime(self) -> datetime.datetime:
         """Date/time of event."""
-        d = self.date
-        t0 = datetime.datetime.min.time()
-        return (
-            d
-            if isinstance(d, datetime.datetime)
-            else datetime.datetime.combine(d, t0).replace(tzinfo=datetime.timezone.utc)
-        )
+        return utils.as_datetime(self.date)
 
     @property
     def has_time(self) -> bool:
diff --git a/agenda/utils.py b/agenda/utils.py
new file mode 100644
index 0000000..0324df3
--- /dev/null
+++ b/agenda/utils.py
@@ -0,0 +1,23 @@
+"""Utility functions."""
+
+import datetime
+
+DateOrDateTime = datetime.datetime | datetime.date
+
+
+def as_date(d: DateOrDateTime) -> datetime.date:
+    """Convert datetime to date."""
+    if isinstance(d, datetime.datetime):
+        return d.date()
+    assert isinstance(d, datetime.date)
+    return d
+
+
+def as_datetime(d: DateOrDateTime) -> datetime.datetime:
+    """Date/time of event."""
+    t0 = datetime.datetime.min.time()
+    return (
+        d
+        if isinstance(d, datetime.datetime)
+        else datetime.datetime.combine(d, t0).replace(tzinfo=datetime.timezone.utc)
+    )

From c41bcc33041adc488fc020cf48e54f59adad85dd Mon Sep 17 00:00:00 2001
From: Edward Betts <edward@4angle.com>
Date: Mon, 1 Jul 2024 22:22:23 +0300
Subject: [PATCH 4/6] Catch errors retrieving FX rates and return cached
 version

---
 agenda/fx.py | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/agenda/fx.py b/agenda/fx.py
index 90cbc5f..efd4d7d 100644
--- a/agenda/fx.py
+++ b/agenda/fx.py
@@ -90,10 +90,14 @@ def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
     except httpx.ConnectError:
         return read_cached_rates(full_path, currencies)
 
+    try:
+        data = json.loads(response.text, parse_float=Decimal)
+    except json.decoder.JSONDecodeError:
+        return read_cached_rates(full_path, currencies)
+
     with open(os.path.join(fx_dir, filename), "w") as file:
         file.write(response.text)
 
-    data = json.loads(response.text, parse_float=Decimal)
     return {
         cur: Decimal(data["quotes"][f"GBP{cur}"])
         for cur in currencies

From b65d79cb633a0d83ef696f5efda70d0e8b2c1e96 Mon Sep 17 00:00:00 2001
From: Edward Betts <edward@4angle.com>
Date: Mon, 1 Jul 2024 22:27:19 +0300
Subject: [PATCH 5/6] Add filters for space launches

---
 agenda/thespacedevs.py  |  7 +++++--
 templates/launches.html | 37 +++++++++++++++++++++++++++++++++++--
 web_view.py             | 28 ++++++++++++++++++++++++++--
 3 files changed, 66 insertions(+), 6 deletions(-)

diff --git a/agenda/thespacedevs.py b/agenda/thespacedevs.py
index 15ad455..3192448 100644
--- a/agenda/thespacedevs.py
+++ b/agenda/thespacedevs.py
@@ -136,7 +136,7 @@ def summarize_launch(launch: Launch) -> Summary:
         "launch_provider": launch_provider,
         "launch_provider_abbrev": launch_provider_abbrev,
         "launch_provider_type": get_nested(launch, ["launch_service_provider", "type"]),
-        "rocket": launch["rocket"]["configuration"]["full_name"],
+        "rocket": launch["rocket"]["configuration"],
         "mission": launch.get("mission"),
         "mission_name": get_nested(launch, ["mission", "name"]),
         "pad_name": launch["pad"]["name"],
@@ -174,7 +174,10 @@ def get_launches(
     existing.sort(reverse=True)
 
     if refresh or not existing or (now - existing[0][0]).seconds > ttl:
-        return next_launch_api(rocket_dir, limit=limit)
+        try:
+            return next_launch_api(rocket_dir, limit=limit)
+        except Exception:
+            pass  # fallback to cached version
 
     f = existing[0][1]
 
diff --git a/templates/launches.html b/templates/launches.html
index a8baede..1756424 100644
--- a/templates/launches.html
+++ b/templates/launches.html
@@ -6,7 +6,40 @@
 <div class="container-fluid mt-2">
   <h1>Space launches</h1>
 
-  {% for launch in rockets %}
+  <h4>Filters</h4>
+
+  <p>Mission type:
+
+  {% if request.args.type %}<a href="{{ request.path }}">🗙</a>{% endif %}
+
+  {% for t in mission_types | sort %}
+      {% if t == request.args.type %}
+        <strong>{{ t }}</strong>
+      {% else %}
+        <a href="?type={{ t }}" class="text-nowrap">
+        {{ t }}
+        </a>
+      {% endif %}
+      {% if not loop.last %} | {% endif %}
+  {% endfor %}
+  </p>
+
+  <p>Vehicle:
+  {% if request.args.rocket %}<a href="{{ request.path }}">🗙</a>{% endif %}
+
+  {% for r in rockets | sort %}
+      {% if r == request.args.rockets %}
+        <strong>{{ r }}</strong>
+      {% else %}
+        <a href="?rocket={{ r }}" class="text-nowrap">
+        {{ r }}
+        </a>
+      {% endif %}
+      {% if not loop.last %} | {% endif %}
+  {% endfor %}
+  </p>
+
+  {% for launch in launches %}
     {% set highlight =" bg-primary-subtle" if launch.slug in config.FOLLOW_LAUNCHES else "" %}
     {% set country = get_country(launch.country_code) %}
     <div class="row{{highlight}}">
@@ -24,7 +57,7 @@
       <div class="col">
         <div>
         {{ country.flag }}
-        {{ launch.rocket }}
+        {{ launch.rocket.full_name }}
         &ndash;
         <strong>{{launch.mission.name }}</strong>
         &ndash;
diff --git a/web_view.py b/web_view.py
index 2cd1e80..c3e663a 100755
--- a/web_view.py
+++ b/web_view.py
@@ -145,10 +145,34 @@ def launch_list() -> str:
     now = datetime.now()
     data_dir = app.config["DATA_DIR"]
     rocket_dir = os.path.join(data_dir, "thespacedevs")
-    rockets = agenda.thespacedevs.get_launches(rocket_dir, limit=100)
+    launches = agenda.thespacedevs.get_launches(rocket_dir, limit=100)
+
+    mission_type_filter = flask.request.args.get("type")
+    rocket_filter = flask.request.args.get("rocket")
+
+    mission_types = {
+        launch["mission"]["type"] for launch in launches if launch["mission"]
+    }
+
+    rockets = {launch["rocket"]["full_name"] for launch in launches}
+
+    launches = [
+        launch
+        for launch in launches
+        if (
+            not mission_type_filter
+            or (launch["mission"] and launch["mission"]["type"] == mission_type_filter)
+        )
+        and (not rocket_filter or launch["rocket"]["full_name"] == rocket_filter)
+    ]
 
     return flask.render_template(
-        "launches.html", rockets=rockets, now=now, get_country=agenda.get_country
+        "launches.html",
+        launches=launches,
+        rockets=rockets,
+        now=now,
+        get_country=agenda.get_country,
+        mission_types=mission_types,
     )
 
 

From 4e328d401e89089e0c5c8e2e383c359b7b520181 Mon Sep 17 00:00:00 2001
From: Edward Betts <edward@4angle.com>
Date: Mon, 1 Jul 2024 22:28:15 +0300
Subject: [PATCH 6/6] No wrap for date in weekend list

---
 templates/weekends.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/templates/weekends.html b/templates/weekends.html
index e787aa6..f443ff6 100644
--- a/templates/weekends.html
+++ b/templates/weekends.html
@@ -21,7 +21,7 @@
     <td class="text-end">
         {{ weekend.date.isocalendar().week }}
     </td>
-    <td class="text-end">
+    <td class="text-end text-nowrap">
         {{ weekend.date.strftime("%-d %b %Y") }}
     </td>
     {% for day in "saturday", "sunday" %}