garmin

A tool to work with Garmin data.

Home

Source code

git clone https://projects.rickardlindberg.me/scm/garmin.git

Recent events

2025-08-27 21:21 Rickard pushed to garmin

commit 38a02c31ccd6139b25b3b5bd14819030e91bfbec
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Wed Aug 27 21:15:59 2025 +0200

    Quick and dirty way to store shoe information for activities

diff --git a/garmin b/garmin
index 55abca1..5944003 100755
--- a/garmin
+++ b/garmin
@@ -30,6 +30,13 @@ COLOR_BORDER_SOFT = (0.2, 0.2, 0.2)
 DEFAULT_NUMBER_OF_WEEKS = 30
 
 
+MY_SHOES = [
+    None,
+    "Altra Olympus 5",
+    "Saucony Triumph 22",
+]
+
+
 class GtkPanel(Gtk.DrawingArea):
 
     def __init__(self, panel):
@@ -311,6 +318,7 @@ class ActivityEditor:
         self.activity = activity
         self.record_points = []
         self.hovered_point_record = None
+        self.activity.load_extra()
 
     def draw(self, canvas, rect):
         self.record_points = []
@@ -320,6 +328,12 @@ class ActivityEditor:
         max_pace = max(record.pace for record in records)
         record_points = []
         points = []
+        self.shoe_rect = Rect(
+            x=inner.x,
+            y=inner.y+inner.height,
+            width=200,
+            height=20,
+        )
         if max_pace > Pace.in_meters_per_second(0):
             for pace_sub_rect, record in zip(pace_rect.split_into_columns_percents(records.percents), records):
                 r = pace_sub_rect.scale_down(record.pace / max_pace)
@@ -380,6 +394,7 @@ class ActivityEditor:
         canvas.draw_rect(pace_rect, color=COLOR_BORDER_SOFT)
         canvas.draw_rect(hr_rect, color=COLOR_BORDER_SOFT)
         canvas.draw_text(rect.top_slice(20), self.activity.summary)
+        canvas.draw_text(self.shoe_rect, self.activity.format_shoe())
         if self.hovered_point_record:
             point, record = self.hovered_point_record
             canvas.fill_rect(Rect(
@@ -409,7 +424,8 @@ class ActivityEditor:
         pass
 
     def mouse_scroll(self, point, amount):
-        pass
+        if self.shoe_rect.contains(point):
+            self.activity.alternate_shoe(amount)
 
 
 class ActivityDatabase:
@@ -632,6 +648,39 @@ class Activity:
         self.db = db
         self.json = json
 
+    def alternate_shoe(self, amount):
+        self.shoe += amount
+        self.write_shoe()
+
+    def format_shoe(self):
+        return f"Shoe: {MY_SHOES[self.shoe % len(MY_SHOES)]}"
+
+    def load_extra(self):
+        data = self.read_extra()
+        self.shoe = MY_SHOES.index(data.get("shoe"))
+
+    def write_shoe(self):
+        data = self.read_extra()
+        data["shoe"] = MY_SHOES[self.shoe]
+        self.write_extra(data)
+
+    def read_extra(self):
+        if os.path.exists(self.extra_path()):
+            with open(self.extra_path()) as f:
+                return json.load(f)
+        else:
+            return {}
+
+    def write_extra(self, data):
+        with open(self.extra_path(), "w") as f:
+            f.write(json.dumps(data, indent=2))
+
+    def extra_path(self):
+        path = self.db.fit_path(self.json.get("fit_path"))
+        root, ext = os.path.splitext(path)
+        path = root + ".json"
+        return path
+
     def get_paces_at_heart_rate(self, heart_rate, variation):
         paces = []
         subs = []

2025-08-17 21:24 Rickard pushed to garmin

commit 81ace6c61b47f0bd6d506e59f2d5d79528432aa5
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sun Aug 17 21:22:16 2025 +0200

    Fix mistake in graph_area assignment.

diff --git a/garmin b/garmin
index 60b37cf..55abca1 100755
--- a/garmin
+++ b/garmin
@@ -173,8 +173,8 @@ class TotalDistance:
         slots = Timestamp.week_range(DEFAULT_NUMBER_OF_WEEKS)
         activity_groups = [self.activities.get_year_week(x) for x in slots]
         total_max = max(activities.distance for activities in activity_groups)
+        graph_area = rect.deflate(15, 40)
         if total_max > Distance.in_meters(0):
-            graph_area = rect.deflate(15, 40)
             if self.hovered_activity:
                 canvas.draw_text(Rect(
                     x=graph_area.x,

commit 9ff9f6b3fa2bb6c1735e87a32251a77fe60ca501
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sun Aug 17 21:21:10 2025 +0200

    Fix more divide by zero cases.

diff --git a/garmin b/garmin
index f8ba46e..60b37cf 100755
--- a/garmin
+++ b/garmin
@@ -173,43 +173,44 @@ class TotalDistance:
         slots = Timestamp.week_range(DEFAULT_NUMBER_OF_WEEKS)
         activity_groups = [self.activities.get_year_week(x) for x in slots]
         total_max = max(activities.distance for activities in activity_groups)
-        graph_area = rect.deflate(15, 40)
-        if self.hovered_activity:
-            canvas.draw_text(Rect(
-                x=graph_area.x,
-                y=graph_area.y+graph_area.height,
-                width=graph_area.width,
-                height=20,
-            ), self.hovered_activity.summary)
-        for rect, slot, activities in zip(
-            graph_area.split_into_columns(count=len(slots)),
-            slots,
-            activity_groups,
-        ):
-            bar_rect = rect.deflate(dx=5, dy=0).scale_down(activities.distance / total_max)
-            inner_bar_rect = bar_rect.deflate(dx=0, dy=0)
-            y = inner_bar_rect.y + inner_bar_rect.height
-            color = COLOR_A
-            for activity in activities:
-                percent = activity.distance / activities.distance
-                if percent > 0:
-                    my_height = inner_bar_rect.height*percent
-                    activity_rect = Rect(
-                        x=inner_bar_rect.x,
-                        y=y-my_height,
-                        width=inner_bar_rect.width,
-                        height=my_height,
-                    )
-                    self.activity_rects.append((activity, activity_rect))
-                    canvas.fill_rect(activity_rect, color=color)
-                    y -= my_height
-                    if color == COLOR_A:
-                        color = COLOR_B
-                    else:
-                        color = COLOR_A
-            canvas.draw_rect(bar_rect, color=COLOR_BORDER_SOFT, size=2)
-            if bar_rect.height > 0:
-                canvas.draw_text(bar_rect.top_slice(20).move(dy=-20), activities.distance.format())
+        if total_max > Distance.in_meters(0):
+            graph_area = rect.deflate(15, 40)
+            if self.hovered_activity:
+                canvas.draw_text(Rect(
+                    x=graph_area.x,
+                    y=graph_area.y+graph_area.height,
+                    width=graph_area.width,
+                    height=20,
+                ), self.hovered_activity.summary)
+            for rect, slot, activities in zip(
+                graph_area.split_into_columns(count=len(slots)),
+                slots,
+                activity_groups,
+            ):
+                bar_rect = rect.deflate(dx=5, dy=0).scale_down(activities.distance / total_max)
+                inner_bar_rect = bar_rect.deflate(dx=0, dy=0)
+                y = inner_bar_rect.y + inner_bar_rect.height
+                color = COLOR_A
+                for activity in activities:
+                    percent = activity.distance / activities.distance
+                    if percent > 0:
+                        my_height = inner_bar_rect.height*percent
+                        activity_rect = Rect(
+                            x=inner_bar_rect.x,
+                            y=y-my_height,
+                            width=inner_bar_rect.width,
+                            height=my_height,
+                        )
+                        self.activity_rects.append((activity, activity_rect))
+                        canvas.fill_rect(activity_rect, color=color)
+                        y -= my_height
+                        if color == COLOR_A:
+                            color = COLOR_B
+                        else:
+                            color = COLOR_A
+                canvas.draw_rect(bar_rect, color=COLOR_BORDER_SOFT, size=2)
+                if bar_rect.height > 0:
+                    canvas.draw_text(bar_rect.top_slice(20).move(dy=-20), activities.distance.format())
         canvas.draw_rect(graph_area, color=COLOR_BORDER_SOFT, size=1)
         if self.hovered_rect:
             canvas.draw_rect(self.hovered_rect, color=COLOR_BORDER_HARD, size=3)
@@ -319,10 +320,11 @@ class ActivityEditor:
         max_pace = max(record.pace for record in records)
         record_points = []
         points = []
-        for pace_sub_rect, record in zip(pace_rect.split_into_columns_percents(records.percents), records):
-            r = pace_sub_rect.scale_down(record.pace / max_pace)
-            record_points.append((r.top_mid, record))
-            points.append(r.top_mid)
+        if max_pace > Pace.in_meters_per_second(0):
+            for pace_sub_rect, record in zip(pace_rect.split_into_columns_percents(records.percents), records):
+                r = pace_sub_rect.scale_down(record.pace / max_pace)
+                record_points.append((r.top_mid, record))
+                points.append(r.top_mid)
         self.record_points.append((pace_rect, record_points))
         points.insert(0, Point(x=points[0].x, y=pace_rect.y+pace_rect.height))
         points.append(Point(x=points[-1].x, y=pace_rect.y+pace_rect.height))

commit c5663c36ceba086dfe46a040ccfdc32fb3988737
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sun Aug 17 21:13:23 2025 +0200

    Fix one divide by zero.

diff --git a/garmin b/garmin
index 1b42864..f8ba46e 100755
--- a/garmin
+++ b/garmin
@@ -913,11 +913,17 @@ class Pace:
         """
         >>> Pace.in_meters_per_second(2.6).format()
         '6:24 min/km'
+
+        >>> Pace.in_meters_per_second(0).format()
+        'inf min/km'
         """
-        minutes_per_km = 1 / self.km_per_minute
-        minutes = int(minutes_per_km)
-        seconds = int((minutes_per_km-minutes)*60)
-        return f"{minutes}:{seconds:02d} min/km"
+        if self.km_per_minute > 0:
+            minutes_per_km = 1 / self.km_per_minute
+            minutes = int(minutes_per_km)
+            seconds = int((minutes_per_km-minutes)*60)
+            return f"{minutes}:{seconds:02d} min/km"
+        else:
+            return f"inf min/km"
 
 
 class Point:

2025-08-17 10:50 Rickard pushed to garmin

commit 5f39d7b788963094cdd798134721e8c4cf150017
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sun Aug 17 10:49:43 2025 +0200

    Don't import cairo. Figure out the constant instead.

diff --git a/garmin b/garmin
index de8c90f..1b42864 100755
--- a/garmin
+++ b/garmin
@@ -16,7 +16,9 @@ gi.require_version("Gtk", "3.0")
 
 from gi.repository import Gtk
 from gi.repository import Gdk
-import cairo
+
+
+CAIRO_LINE_JOIN_ROUND = 1
 
 
 COLOR_BACKGROUND = (1, 0.7, 1)
@@ -88,7 +90,7 @@ class GtkCanvas:
         self.context.set_line_width(size)
         self.context.set_source_rgb(*color)
         self.context.rectangle(rect.x, rect.y, rect.width, rect.height)
-        self.context.set_line_join(cairo.LineJoin.ROUND)
+        self.context.set_line_join(CAIRO_LINE_JOIN_ROUND)
         self.context.stroke()
 
     def fill_rect(self, rect, color):
@@ -101,7 +103,7 @@ class GtkCanvas:
         self.context.set_source_rgb(*color)
         for point in path:
             self.context.line_to(point.x, point.y)
-        self.context.set_line_join(cairo.LineJoin.ROUND)
+        self.context.set_line_join(CAIRO_LINE_JOIN_ROUND)
         self.context.stroke()
 
     def fill_path(self, path, color, size=2):

2025-08-17 10:39 Rickard pushed to garmin

commit a8a4c3598182055080f83b418e6c8551c58af7be
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Tue Aug 12 21:40:27 2025 +0200

    Document understanding of fromtimestamp in test.

diff --git a/garmin b/garmin
index 70ed726..de8c90f 100755
--- a/garmin
+++ b/garmin
@@ -503,6 +503,9 @@ class Timestamp:
     >>> datetime.datetime.fromtimestamp(0)
     datetime.datetime(1970, 1, 1, 1, 0)
 
+    >>> datetime.datetime.fromtimestamp(0, datetime.timezone.utc)
+    datetime.datetime(1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
+
     >>> d = datetime.datetime(2025, 8, 7, 12, 0, 0)
     >>> d
     datetime.datetime(2025, 8, 7, 12, 0)

commit 0f1be951808fa43d199659e2310ab6a5868c6b5b
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Tue Aug 12 21:29:06 2025 +0200

    Add notes.

diff --git a/notes.md b/notes.md
index 080383f..88c435c 100644
--- a/notes.md
+++ b/notes.md
@@ -1,3 +1,5 @@
+# Notes
+
 * https://wiki.openstreetmap.org/wiki/FIT
 * sudo dnf install gpsbabel
 * gpsbabel -i garmin_fit -f F81G0144.FIT -o gpx,garminextensions -F - | less
@@ -53,6 +55,4 @@ last record in period: 2025-07-11T16:44:55 | 127 bpm | 7:08 min/km
 
 # TODO
 
-~~1. draw each individual event in the box~~
-2. show details when event is hovered
-3. show event in "event editor" when clicked
+* Is average calculation for PaceAtHeartRate correct?

commit 93bc5f011cb43fd80112d0f6a7602bbae7e9164f
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sun Aug 10 13:42:42 2025 +0200

    Always fill cache from db.

diff --git a/garmin b/garmin
index a22e466..70ed726 100755
--- a/garmin
+++ b/garmin
@@ -418,6 +418,8 @@ class ActivityDatabase:
         if os.path.exists(self.path):
             with open(self.path) as f:
                 self.json = json.load(f)
+        for fit_name in glob.glob(os.path.join(os.path.dirname(self.path), "*.fit")):
+            self.import_fit_activity(os.path.basename(fit_name))
 
     def save(self):
         with open(self.path, "w") as f:

commit da1a3a72ba41e335563b7803a8c1f07267ff22b0
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sun Aug 10 13:32:43 2025 +0200

    More readable db json for better git diff.

diff --git a/garmin b/garmin
index a41642f..a22e466 100755
--- a/garmin
+++ b/garmin
@@ -421,7 +421,7 @@ class ActivityDatabase:
 
     def save(self):
         with open(self.path, "w") as f:
-            f.write(json.dumps(self.json))
+            f.write(json.dumps(self.json, indent=2))
 
     def fit_path(self, fit_name):
         return os.path.join(os.path.dirname(self.path), fit_name)

commit a24997c301390f8f92002f374d6c4aa8f055a88b
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sun Aug 10 13:32:28 2025 +0200

    Take db_dir and import_dir from args.

diff --git a/garmin b/garmin
index 565c61f..a41642f 100755
--- a/garmin
+++ b/garmin
@@ -1016,9 +1016,10 @@ if __name__ == "__main__":
     if "--selftest" in sys.argv[1:]:
         print(doctest.testmod())
     else:
-        db = ActivityDatabase("db/db.json")
+        db_dir, import_dir = sys.argv[1:]
+        db = ActivityDatabase(os.path.join(db_dir, "cache.json"))
         db.load()
-        db.import_fit_files_from_folder("GARMIN/GARMIN/ACTIVITY")
+        db.import_fit_files_from_folder(import_dir)
         db.save()
         GtkApp().open_diagram(Dashboard([
             TotalDistance(db),

2025-08-10 11:11 Rickard pushed to garmin

commit 1c51a081801a444f2cd72870c96a1d258ac16f0e
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sun Aug 10 11:11:26 2025 +0200

    Nicer line join.

diff --git a/garmin b/garmin
index 4aadfb3..565c61f 100755
--- a/garmin
+++ b/garmin
@@ -16,6 +16,7 @@ gi.require_version("Gtk", "3.0")
 
 from gi.repository import Gtk
 from gi.repository import Gdk
+import cairo
 
 
 COLOR_BACKGROUND = (1, 0.7, 1)
@@ -87,6 +88,7 @@ class GtkCanvas:
         self.context.set_line_width(size)
         self.context.set_source_rgb(*color)
         self.context.rectangle(rect.x, rect.y, rect.width, rect.height)
+        self.context.set_line_join(cairo.LineJoin.ROUND)
         self.context.stroke()
 
     def fill_rect(self, rect, color):
@@ -99,6 +101,7 @@ class GtkCanvas:
         self.context.set_source_rgb(*color)
         for point in path:
             self.context.line_to(point.x, point.y)
+        self.context.set_line_join(cairo.LineJoin.ROUND)
         self.context.stroke()
 
     def fill_path(self, path, color, size=2):

commit f1238bd2133c66396bf9909638c3c97510ae219f
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sun Aug 10 11:06:35 2025 +0200

    Highlight hoovered record.

diff --git a/garmin b/garmin
index 2ced040..4aadfb3 100755
--- a/garmin
+++ b/garmin
@@ -303,20 +303,22 @@ class ActivityEditor:
 
     def __init__(self, activity):
         self.activity = activity
-        self.record_rects = []
-        self.hovered_record = None
+        self.record_points = []
+        self.hovered_point_record = None
 
     def draw(self, canvas, rect):
-        self.record_rects = []
+        self.record_points = []
         inner = rect.deflate(dx=5, dy=30)
         pace_rect, hr_rect = [x.deflate(5, 5) for x in inner.split_into_rows(2)]
         records = self.activity.records
         max_pace = max(record.pace for record in records)
+        record_points = []
         points = []
         for pace_sub_rect, record in zip(pace_rect.split_into_columns_percents(records.percents), records):
             r = pace_sub_rect.scale_down(record.pace / max_pace)
-            self.record_rects.append((r, record))
+            record_points.append((r.top_mid, record))
             points.append(r.top_mid)
+        self.record_points.append((pace_rect, record_points))
         points.insert(0, Point(x=points[0].x, y=pace_rect.y+pace_rect.height))
         points.append(Point(x=points[-1].x, y=pace_rect.y+pace_rect.height))
         canvas.fill_path(
@@ -338,13 +340,15 @@ class ActivityEditor:
                 r,
                 lap.averate_pace.format()
             )
+        record_points = []
         max_hr = max(record.heart_rate for record in records)
         if max_hr > HeartRate.in_bpm(0):
             points = []
             for hr_sub_rect, record in zip(hr_rect.split_into_columns_percents(records.percents), records):
                 r = hr_sub_rect.scale_down(record.heart_rate / max_hr)
-                self.record_rects.append((r, record))
+                record_points.append((r.top_mid, record))
                 points.append(r.top_mid)
+            self.record_points.append((hr_rect, record_points))
             points.insert(0, Point(x=points[0].x, y=hr_rect.y+hr_rect.height))
             points.append(Point(x=points[-1].x, y=hr_rect.y+hr_rect.height))
             canvas.fill_path(
@@ -369,20 +373,30 @@ class ActivityEditor:
         canvas.draw_rect(pace_rect, color=COLOR_BORDER_SOFT)
         canvas.draw_rect(hr_rect, color=COLOR_BORDER_SOFT)
         canvas.draw_text(rect.top_slice(20), self.activity.summary)
-        if self.hovered_record:
+        if self.hovered_point_record:
+            point, record = self.hovered_point_record
+            canvas.fill_rect(Rect(
+                x=point.x-1,
+                y=point.y-1,
+                width=3,
+                height=3,
+            ), color=(1, 1, 0.2))
             canvas.draw_text(Rect(
                 x=inner.x,
                 y=inner.y+inner.height,
                 width=inner.width,
                 height=20,
-            ), self.hovered_record.summary)
+            ), record.summary)
 
     def mouse(self, point):
-        for rect, record in self.record_rects:
-            if rect.contains(point):
-                self.hovered_record = record
+        for rect, points in self.record_points:
+            if rect.contains(point) and points:
+                self.hovered_point_record = min(
+                    points,
+                    key=lambda point_record: point_record[0].x_y_distance_to(point)
+                )
                 return
-        self.hovered_record = None
+        self.hovered_point_record = None
 
     def mouse_click(self, point, app):
         pass
@@ -902,6 +916,9 @@ class Point:
         self.x = x
         self.y = y
 
+    def x_y_distance_to(self, other):
+        return (int(abs(self.x-other.x)), int(abs(self.y-other.y)))
+
 
 class Rect:
 

commit e5fc5c307ffc2a939273eb12ec36b21a8933e5bb
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sun Aug 10 10:42:33 2025 +0200

    Add assert.

diff --git a/garmin b/garmin
index aae18fc..2ced040 100755
--- a/garmin
+++ b/garmin
@@ -812,6 +812,7 @@ class Records:
                     (record.date - last_date) / self.total_time
                 )
                 last_date = record.date
+        assert len(records) == 0
         return percents
 
     def interpolate(self):

commit 80c1ab05ec19cdec4e6322dd30c60804d39cf4ba
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sun Aug 10 10:38:36 2025 +0200

    Better handling of multiple timer periods.

diff --git a/garmin b/garmin
index 32b7416..aae18fc 100755
--- a/garmin
+++ b/garmin
@@ -440,6 +440,19 @@ class ActivityDatabase:
         decoder = Decoder(stream)
         messages, errors = decoder.read(convert_datetimes_to_dates=True)
         activity["fit_path"] = fit_name
+        activity["timer_periods"] = []
+        last_start = None
+        for event in messages.get("event_mesgs", []):
+            if event["event"] == "timer":
+                if event["event_type"] == "start":
+                    last_start = event["timestamp"].timestamp()
+                elif event["event_type"] == "stop_all":
+                    assert last_start is not None
+                    activity["timer_periods"].append({
+                        "start": last_start,
+                        "end": event["timestamp"].timestamp(),
+                    })
+                    last_start = None
         if messages["file_id_mesgs"][0]["type"] == "activity":
             activity["type"] = messages["session_mesgs"][0]["sport"]
             activity["distance"] = messages["session_mesgs"][0]["total_distance"]
@@ -493,6 +506,9 @@ class Timestamp:
     def __lt__(self, other):
         return self.timestamp < other.timestamp
 
+    def __le__(self, other):
+        return self.timestamp <= other.timestamp
+
     def __sub__(self, other):
         return Duration.in_seconds(self.timestamp - other.timestamp)
 
@@ -644,6 +660,10 @@ class Activity:
     def laps(self):
         return [Lap(x) for x in self.json.get("laps", [])]
 
+    @property
+    def timer_periods(self):
+        return [TimerPeriod(x) for x in self.json["timer_periods"]]
+
     @property
     def summary(self):
         return f"{self.date.format()} | {self.type} | {self.distance.format()} | {self.duration.format()}"
@@ -657,6 +677,7 @@ class Activity:
             start_date=self.date,
             total_time=self.duration,
             laps=Laps([Lap(x) for x in messages.get("lap_mesgs", [])]),
+            timer_periods=self.timer_periods,
             records=[
                 Record(json)
                 for json
@@ -696,6 +717,23 @@ class Lap:
         return Pace.in_meters_per_second(self.json["avg_speed"])
 
 
+class TimerPeriod:
+
+    def __init__(self, json):
+        self.json = json
+
+    @property
+    def start(self):
+        return Timestamp(self.json["start"])
+
+    @property
+    def end(self):
+        return Timestamp(self.json["end"])
+
+    def contains(self, timestamp):
+        return self.start <= timestamp <= self.end
+
+
 class Duration:
 
     @classmethod
@@ -742,11 +780,12 @@ class Duration:
 
 class Records:
 
-    def __init__(self, start_date, total_time, laps, records):
+    def __init__(self, start_date, total_time, laps, records, timer_periods):
         self.start_date = start_date
         self.total_time = total_time
         self.laps = laps
         self.records = records
+        self.timer_periods = timer_periods
 
     def __iter__(self):
         return iter(self.interpolate())
@@ -763,14 +802,16 @@ class Records:
 
     @property
     def percents(self):
-        total_time = self.total_time
-        last_date = self.start_date
         percents = []
-        for record in self.interpolate():
-            percents.append(
-                (record.date - last_date) / total_time
-            )
-            last_date = record.date
+        records = self.interpolate()
+        for timer_period in self.timer_periods:
+            last_date = timer_period.start
+            while records and timer_period.contains(records[0].date):
+                record = records.pop(0)
+                percents.append(
+                    (record.date - last_date) / self.total_time
+                )
+                last_date = record.date
         return percents
 
     def interpolate(self):
diff --git a/notes.md b/notes.md
index 2457713..080383f 100644
--- a/notes.md
+++ b/notes.md
@@ -46,6 +46,11 @@
             print((dur_1.seconds, dur_2.seconds))
             assert abs(dur_1.seconds-dur_2.seconds) < 0.00001
 
+first record in period: 2025-07-11T15:29:59 | 121 bpm | 27:30 min/km
+last record in period: 2025-07-11T16:04:22 | 137 bpm | 6:25 min/km
+first record in period: 2025-07-11T16:04:44 | 154 bpm | 4:13 min/km
+last record in period: 2025-07-11T16:44:55 | 127 bpm | 7:08 min/km
+
 # TODO
 
 ~~1. draw each individual event in the box~~

commit 2c199bafd7d5a97be6fb2074ede30ab213f75c8c
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sun Aug 10 10:03:05 2025 +0200

    Fix percentage_laps.

diff --git a/garmin b/garmin
index 0d8a6a3..32b7416 100755
--- a/garmin
+++ b/garmin
@@ -653,11 +653,16 @@ class Activity:
         stream = Stream.from_file(self.fit_path)
         decoder = Decoder(stream)
         messages, errors = decoder.read(convert_datetimes_to_dates=True)
-        return Records(self.date, Laps([Lap(x) for x in messages.get("lap_mesgs", [])]), [
-            Record(json)
-            for json
-            in messages["record_mesgs"]
-        ])
+        return Records(
+            start_date=self.date,
+            total_time=self.duration,
+            laps=Laps([Lap(x) for x in messages.get("lap_mesgs", [])]),
+            records=[
+                Record(json)
+                for json
+                in messages["record_mesgs"]
+            ],
+        )
 
 
 class Laps:
@@ -737,8 +742,9 @@ class Duration:
 
 class Records:
 
-    def __init__(self, start_date, laps, records):
+    def __init__(self, start_date, total_time, laps, records):
         self.start_date = start_date
+        self.total_time = total_time
         self.laps = laps
         self.records = records
 
@@ -748,19 +754,12 @@ class Records:
     def __len__(self):
         return len(self.interpolate())
 
-    @property
-    def total_time(self):
-        return self.interpolate()[-1].date - self.start_date
-
     @property
     def percents_laps(self):
-        total_time = self.total_time
-        percents = []
-        for lap in self.laps:
-            percents.append(
-                lap.duration / total_time
-            )
-        return percents
+        return [
+            lap.duration / self.total_time
+            for lap in self.laps
+        ]
 
     @property
     def percents(self):

commit a07ff60a077dda5393045cc3b3f9a486461b4bf2
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sun Aug 10 10:01:30 2025 +0200

    Add notes.

diff --git a/notes.md b/notes.md
index 9394812..2457713 100644
--- a/notes.md
+++ b/notes.md
@@ -20,6 +20,32 @@
     unzip UploadedFiles_0-_Part1.zip
     mv *.fit ~/projects/garmin/GARMIN/GARMIN/ACTIVITY/
 
+* session.start_time is when the session started
+* session.timestamp is when the message was created
+
+* session start: 1121174999
+* start: 1121174999
+* stop: 1121177062
+* start: 1121177084
+    * diff: 1121177084-1121177062=22
+* stop: 1121179495
+
+* first record: 1121174999
+* record before split: 1121177062 (328)
+* record after split: 1121177084 (329)
+* last record: 1121179495
+
+* lap 1 start time: 1121174999
+
+* Assert lap durations sums to activity duration
+
+        for x in db.json.get("activities"):
+            a = Activity(db, x)
+            dur_1 = a.duration
+            dur_2 = sum([lap.duration for lap in a.laps], start=Duration.in_seconds(0))
+            print((dur_1.seconds, dur_2.seconds))
+            assert abs(dur_1.seconds-dur_2.seconds) < 0.00001
+
 # TODO
 
 ~~1. draw each individual event in the box~~

commit 7e9f35e4b3d77f56da80472ee2927b3e4cd2fad7
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sat Aug 9 15:57:53 2025 +0200

    Remove unused key event handler.

diff --git a/garmin b/garmin
index e7c9d35..0d8a6a3 100755
--- a/garmin
+++ b/garmin
@@ -34,13 +34,11 @@ class GtkPanel(Gtk.DrawingArea):
         self.add_events(
             self.get_events()
             | Gdk.EventMask.POINTER_MOTION_MASK
-            | Gdk.EventMask.KEY_PRESS_MASK
             | Gdk.EventMask.BUTTON_PRESS_MASK
             | Gdk.EventMask.SCROLL_MASK
         )
         self.connect("draw", self.on_draw)
         self.connect("motion-notify-event", self.on_motion_notify_event)
-        self.connect("key-press-event", self.on_key_press_event)
         self.connect("button-press-event", self.on_button_press_event)
         self.connect("scroll-event", self.on_scroll_event)
         self.set_can_focus(True)
@@ -64,34 +62,6 @@ class GtkPanel(Gtk.DrawingArea):
         self.panel.mouse(Point(x=x, y=y))
         self.queue_draw()
 
-    def on_key_press_event(self, widget, event):
-        #unicode = Gdk.keyval_to_unicode(event.keyval)
-        #if event.keyval == 65361:
-        #    self.editor.cursor_backward()
-        #elif event.keyval == 65363:
-        #    self.editor.cursor_forward()
-        #elif event.keyval == 65288:  # backspace
-        #    self.editor.delete_whole_or_before()
-        #elif event.keyval == 65535:  # del
-        #    self.editor.delete_whole_or_after()
-        #elif event.keyval == 65293:  # enter
-        #    self.editor.update_text("\n")
-        #elif event.state & Gdk.ModifierType.CONTROL_MASK and unicode == ord("h"):
-        #    self.editor.selection_expand()
-        #elif event.state & Gdk.ModifierType.CONTROL_MASK and unicode == ord("l"):
-        #    self.editor.selection_contract()
-        #elif event.state & Gdk.ModifierType.CONTROL_MASK and unicode == ord("j"):
-        #    self.editor.select_next_node()
-        #elif event.state & Gdk.ModifierType.CONTROL_MASK and unicode == ord("k"):
-        #    self.editor.select_previous_node()
-        #elif event.state & Gdk.ModifierType.CONTROL_MASK and unicode == ord("s"):
-        #    self.editor.save()
-        #elif unicode >= 32:
-        #    self.editor.update_text(chr(unicode))
-        #else:
-        #    return
-        self.queue_draw()
-
     def on_button_press_event(self, widget, event):
         x, y = self.translate_coordinates(self, event.x, event.y)
         self.panel.mouse_click(point=Point(x=x, y=y), app=GtkApp())

2025-08-09 15:27 Rickard pushed to garmin

commit 267f022f00a0a995bb8ccf756932ef17129b997a
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sat Aug 9 15:24:39 2025 +0200

    Better name.

diff --git a/guistat.py b/garmin
similarity index 100%
rename from guistat.py
rename to garmin

commit 992a4c3d6f4020de3b67ba965db554672488db38
Author: Rickard Lindberg <rickard@rickardlindberg.me>
Date:   Sat Aug 9 15:23:16 2025 +0200

    Remove old stat.py.

diff --git a/stat.py b/stat.py
deleted file mode 100755
index 8049cc5..0000000
--- a/stat.py
+++ /dev/null
@@ -1,153 +0,0 @@
-#!/usr/bin/env python
-
-from collections import namedtuple
-from datetime import datetime
-from datetime import timedelta
-import math
-import subprocess
-import xml.etree.ElementTree as ET
-
-from garmin_fit_sdk import Decoder, Stream
-
-xml = subprocess.check_output([
-    "gpsbabel",
-    "-i", "garmin_fit",
-    "-f", "GARMIN/GARMIN/ACTIVITY/F81G0144.FIT",
-    "-o", "gpx,garminextensions",
-    "-F", "-",
-])
-
-root = ET.fromstring(xml)
-
-def parse_node_text(node, fn):
-    if node is None:
-        return None
-    else:
-        return fn(node.text)
-
-class Coordinate(namedtuple("Coordinate", "lat,lon")):
-
-    def distance_to(self, other):
-        delta_lat = math.radians(self.lat - other.lat)
-        delta_lon = math.radians(self.lon - other.lon)
-        a = math.sin(delta_lat / 2)**2 + math.cos(math.radians(other.lat)) * math.cos(math.radians(self.lat)) * math.sin(delta_lon / 2) ** 2
-        great_circle_distance = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
-        return 6371.008 * great_circle_distance
-
-class TrackPoint(namedtuple("TrackPoint", "coordinate,ele,time,hr,cad")):
-
-    def distance_to(self, other):
-        return self.coordinate.distance_to(other)
-
-    def diff(self, other):
-        return Diff(
-            distance=self.distance_to(other.coordinate),
-            time=(self.time-other.time).total_seconds(),
-        )
-
-class Diff(namedtuple("Diff", "distance,time")):
-    pass
-
-class Segment(namedtuple("Segment", "track_points")):
-
-    def split(self, waypoints):
-        if waypoints:
-            waypoint = waypoints[0]
-            x_distance = None
-            for split_index, point in enumerate(self.track_points):
-                if point is not None:
-                    distance = point.distance_to(waypoint)
-                    if distance < 100/1000:
-                        break
-            lap = self.track_points[:split_index]
-            rest = self.track_points[split_index:]
-            return Laps(segments=[Segment(track_points=lap)]).merge(Segment(track_points=rest).split(waypoints[1:]))
-        else:
-            return Laps([self])
-
-    def stat(self):
-        total_seconds = 0
-        total_distance = 0
-        last = None
-        for point in self.track_points:
-            if last is not None and point is not None:
-                diff = point.diff(last)
-                total_seconds += diff.time
-                total_distance += diff.distance
-            last = point
-        print(timedelta(seconds=total_seconds))
-        print(total_distance)
-
-class Laps(namedtuple("Laps", "segments")):
-
-    def merge(self, other):
-        return Laps(self.segments+other.segments)
-
-    def stat(self):
-        for index, segment in enumerate(self.segments):
-            print(f"Lap {index+1}:")
-            segment.stat()
-        print("Total:")
-        total_seconds = 0
-        total_distance = 0
-        last = None
-        for segment in self.segments:
-            for point in segment.track_points:
-                if last is not None and point is not None:
-                    diff = point.diff(last)
-                    total_seconds += diff.time
-                    total_distance += diff.distance
-                last = point
-        print(timedelta(seconds=total_seconds))
-        print(total_distance)
-
-class Track(namedtuple("Track", "segments,waypoints")):
-
-    def normalize(self):
-        track_points = []
-        for index, segment in enumerate(self.segments):
-            if index > 0:
-                track_points.append(None)
-            track_points.extend(segment.track_points)
-        return Segment(track_points).split(self.waypoints)
-
-    def stat(self):
-        self.normalize().stat()
-
-def parse():
-    for trk in root.findall("{http://www.topografix.com/GPX/1/1}trk"):
-        segments = []
-        for trkseg in trk.findall("{http://www.topografix.com/GPX/1/1}trkseg"):
-            track_points = []
-            for trkpt in trkseg.findall("{http://www.topografix.com/GPX/1/1}trkpt"):
-                lat = float(trkpt.attrib["lat"])
-                lon = float(trkpt.attrib["lon"])
-                coordinate = Coordinate(lat=lat, lon=lon)
-                ele = float(trkpt.find("{http://www.topografix.com/GPX/1/1}ele").text)
-                time = datetime.fromisoformat(trkpt.find("{http://www.topografix.com/GPX/1/1}time").text)
-                extensions = trkpt.find("{http://www.topografix.com/GPX/1/1}extensions")
-                track_point_extension = extensions.find("{http://www.garmin.com/xmlschemas/TrackPointExtension/v1}TrackPointExtension")
-                hr = parse_node_text(
-                    track_point_extension.find("{http://www.garmin.com/xmlschemas/TrackPointExtension/v1}hr"),
-                    int
-                )
-                cad = parse_node_text(
-                    track_point_extension.find("{http://www.garmin.com/xmlschemas/TrackPointExtension/v1}cad"),
-                    int
-                )
-                track_points.append(TrackPoint(coordinate=coordinate, ele=ele, time=time, hr=hr, cad=cad))
-            segments.append(Segment(track_points=track_points))
-    waypoints = []
-    for wpt in root.findall("{http://www.topografix.com/GPX/1/1}wpt"):
-        lat = float(wpt.attrib["lat"])
-        lon = float(wpt.attrib["lon"])
-        waypoints.append(Coordinate(lat=lat, lon=lon))
-    return Track(segments=segments, waypoints=waypoints)
-
-if __name__ == "__main__":
-    #parse().stat()
-    stream = Stream.from_file("GARMIN/GARMIN/ACTIVITY/F81G0144.FIT")
-    decoder = Decoder(stream)
-    messages, errors = decoder.read(convert_datetimes_to_dates=False)
-    import json
-    print(json.dumps(messages))

2025-08-09 15:22 Rickard pushed to garmin

fatal: Invalid revision range 0000000000000000000000000000000000000000..fc69f5ccd1f6311cb4f0fc402e256a23b5f6d376