├── .github ├── FUNDING.yml └── workflows │ └── sync_garmin_to_notion.yml ├── .gitignore ├── LICENSE ├── README.md ├── daily-steps.py ├── garmin-activities.py ├── personal-records.py ├── requirements.txt └── sleep-data.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [chloevoyer] 4 | buy_me_a_coffee: cvoyer 5 | -------------------------------------------------------------------------------- /.github/workflows/sync_garmin_to_notion.yml: -------------------------------------------------------------------------------- 1 | name: Sync Garmin to Notion 2 | 3 | on: 4 | schedule: 5 | # This cron job will run every 15 minutes from 6 AM to 11 PM Eastern Time 6 | # - cron: '*/10 10-23 * * *' # Every 15 minutes from 10:00 to 23:45 UTC 7 | # - cron: '*/10 0-3 * * *' # Every 15 minutes from 00:00 to 03:45 UTC 8 | - cron: '0 1 * * *' # Daily 9 | workflow_dispatch: 10 | env: 11 | TZ: 'America/Montreal' 12 | 13 | jobs: 14 | sync: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: '3.11' 23 | 24 | - name: Cache pip packages 25 | uses: actions/cache@v3 26 | with: 27 | path: ~/.cache/pip 28 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} 29 | restore-keys: | 30 | ${{ runner.os }}-pip- 31 | 32 | - name: Install dependencies 33 | run: | 34 | python -m pip install --upgrade pip setuptools wheel 35 | pip install -r requirements.txt 36 | 37 | - name: Run script 38 | env: 39 | GARMIN_EMAIL: ${{ secrets.GARMIN_EMAIL }} 40 | GARMIN_PASSWORD: ${{ secrets.GARMIN_PASSWORD }} 41 | NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} 42 | NOTION_DB_ID: ${{ secrets.NOTION_DB_ID }} 43 | NOTION_PR_DB_ID: ${{ secrets.NOTION_PR_DB_ID }} 44 | NOTION_STEPS_DB_ID: ${{ secrets.NOTION_STEPS_DB_ID }} 45 | NOTION_SLEEP_DB_ID: ${{ secrets.NOTION_SLEEP_DB_ID }} 46 | TZ: 'America/Montreal' 47 | run: | 48 | python garmin-activities.py 49 | python personal-records.py 50 | python daily-steps.py 51 | python sleep-data.py 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 chloevoyer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Sync Garmin to Notion](https://github.com/chloevoyer/garmin-to-notion/actions/workflows/sync_garmin_to_notion.yml/badge.svg?branch=main)](https://github.com/chloevoyer/garmin-to-notion/actions/workflows/sync_garmin_to_notion.yml) 2 | # Garmin to Notion Integration :watch: 3 | This project connects your Garmin activities and personal records to your Notion database, allowing you to keep track of your performance metrics in one place. 4 | 5 | ## Features :sparkles: 6 | 🔄 Automatically sync Garmin activities to Notion in real-time 7 | 📊 Track detailed activity metrics (distance, pace, heart rate) 8 | 🎯 Extract and track personal records (fastest 1K, longest ride) 9 | 👣 Optional daily steps tracker 10 | 😴 Optional sleep data tracker 11 | 🤖 Zero-touch automation once configured 12 | 📱 Compatible with all Garmin activities and devices 13 | 🔧 Easy setup with clear instructions and minimal coding required 14 | 15 | ## Prerequisites :hammer_and_wrench: 16 | - A Notion account with API access. 17 | - A Garmin Connect account to pull activity data. 18 | - If you wish to sync your Peloton workouts with Garmin, see [peloton-to-garmin](https://github.com/philosowaffle/peloton-to-garmin) 19 | ## Getting Started :dart: 20 | A detailed step-by-step guide is provided on my Notion template [here](https://chloevoyer.notion.site/Set-up-Guide-17915ce7058880559a3ac9f8a0720046). 21 | For more advanced users, follow these steps to set up the integration: 22 | ### 1. Fork this GitHub Repository 23 | ### 2. Duplicate my [Notion Template](https://www.notion.so/templates/fitness-tracker-738) 24 | * Save your Activities and Personal Records database ID (you will need it for step 4) 25 | * Optional: Daily Steps database ID 26 | * Look at the URL: notion.so/username/[string-of-characters] 27 | * The database ID is everything after your “username/“ and before the “?v” 28 | ### 3. Create Notion Token 29 | * Go to [Notion Integrations](https://www.notion.so/profile/integrations). 30 | * [Create](https://developers.notion.com/docs/create-a-notion-integration) a new integration and copy the integration token. 31 | * [Share](https://www.notion.so/help/add-and-manage-connections-with-the-api#enterprise-connection-settings) the integration with the target database in Notion. 32 | ### 4. Set Environment Secrets 33 | * Environment secrets to define: 34 | * GARMIN_EMAIL 35 | * GARMIN_PASSWORD 36 | * NOTION_TOKEN 37 | * NOTION_DB_ID 38 | * NOTION_PR_DB_ID 39 | * NOTION_STEPS_DB_ID (optional) 40 | * NOTION_SLEEP_DB_ID (optional) 41 | ### 5. Run Scripts (if not using automatic workflow) 42 | * Run [garmin-activities.py](https://github.com/chloevoyer/garmin-to-notion/blob/main/garmin-activities.py) to sync your Garmin activities to Notion. 43 | `python garmin-activities.py` 44 | * Run [person-records.py](https://github.com/chloevoyer/garmin-to-notion/blob/main/personal-records.py) to extract activity records (e.g., fastest run, longest ride). 45 | `python personal-records.py` 46 | ## Example Configuration :pencil: 47 | You can customize the scripts to fit your needs by modifying environment variables and Notion database settings. 48 | 49 | Here is a screenshot of what my Notion dashboard looks like: 50 | ![garmin-to-notion-template](https://github.com/user-attachments/assets/b37077cc-fe87-466f-9424-8ba9e4efa909) 51 | 52 | 53 | My Notion template is available for free and can be duplicated to your Notion [here](https://www.notion.so/templates/fitness-tracker-738) 54 | 55 | ## Acknowledgements :raised_hands: 56 | - Reference dictionary and examples can be found in [cyberjunky/python-garminconnect](https://github.com/cyberjunky/python-garminconnect.git). 57 | - This project was inspired by [n-kratz/garmin-notion](https://github.com/n-kratz/garmin-notion.git). 58 | ## Contributing :handshake: 59 | Contributions are welcome! If you find a bug or want to add a feature, feel free to open an issue or submit a pull request. Financial contributions are also greatly appreciated :blush: 60 | 61 | Buy Me A Coffee 62 | 63 | ## :copyright: License 64 | This project is licensed under the MIT License. See the LICENSE file for more details. -------------------------------------------------------------------------------- /daily-steps.py: -------------------------------------------------------------------------------- 1 | from datetime import date, timedelta 2 | from garminconnect import Garmin 3 | from notion_client import Client 4 | from dotenv import load_dotenv 5 | import os 6 | 7 | def get_all_daily_steps(garmin): 8 | """ 9 | Get last x days of daily step count data from Garmin Connect. 10 | """ 11 | startdate = date.today() - timedelta(days=1) 12 | daterange = [startdate + timedelta(days=x) 13 | for x in range((date.today() - startdate).days)] # excl. today 14 | daily_steps = [] 15 | for d in daterange: 16 | daily_steps += garmin.get_daily_steps(d.isoformat(), d.isoformat()) 17 | return daily_steps 18 | 19 | def daily_steps_exist(client, database_id, activity_date): 20 | """ 21 | Check if daily step count already exists in the Notion database. 22 | """ 23 | query = client.databases.query( 24 | database_id=database_id, 25 | filter={ 26 | "and": [ 27 | {"property": "Date", "date": {"equals": activity_date}}, 28 | {"property": "Activity Type", "title": {"equals": "Walking"}} 29 | ] 30 | } 31 | ) 32 | results = query['results'] 33 | return results[0] if results else None 34 | 35 | def steps_need_update(existing_steps, new_steps): 36 | """ 37 | Compare existing steps data with imported data to determine if an update is needed. 38 | """ 39 | existing_props = existing_steps['properties'] 40 | activity_type = "Walking" 41 | 42 | return ( 43 | existing_props['Total Steps']['number'] != new_steps.get('totalSteps') or 44 | existing_props['Step Goal']['number'] != new_steps.get('stepGoal') or 45 | existing_props['Total Distance (km)']['number'] != new_steps.get('totalDistance') or 46 | existing_props['Activity Type']['title'] != activity_type 47 | ) 48 | 49 | def update_daily_steps(client, existing_steps, new_steps): 50 | """ 51 | Update an existing daily steps entry in the Notion database with new data. 52 | """ 53 | total_distance = new_steps.get('totalDistance') 54 | if total_distance is None: 55 | total_distance = 0 56 | properties = { 57 | "Activity Type": {"title": [{"text": {"content": "Walking"}}]}, 58 | "Total Steps": {"number": new_steps.get('totalSteps')}, 59 | "Step Goal": {"number": new_steps.get('stepGoal')}, 60 | "Total Distance (km)": {"number": round(total_distance / 1000, 2)} 61 | } 62 | 63 | update = { 64 | "page_id": existing_steps['id'], 65 | "properties": properties, 66 | } 67 | 68 | client.pages.update(**update) 69 | 70 | def create_daily_steps(client, database_id, steps): 71 | """ 72 | Create a new daily steps entry in the Notion database. 73 | """ 74 | total_distance = steps.get('totalDistance') 75 | if total_distance is None: 76 | total_distance = 0 77 | properties = { 78 | "Activity Type": {"title": [{"text": {"content": "Walking"}}]}, 79 | "Date": {"date": {"start": steps.get('calendarDate')}}, 80 | "Total Steps": {"number": steps.get('totalSteps')}, 81 | "Step Goal": {"number": steps.get('stepGoal')}, 82 | "Total Distance (km)": {"number": round(total_distance / 1000, 2)} 83 | } 84 | 85 | page = { 86 | "parent": {"database_id": database_id}, 87 | "properties": properties, 88 | } 89 | 90 | client.pages.create(**page) 91 | 92 | def main(): 93 | load_dotenv() 94 | 95 | # Initialize Garmin and Notion clients using environment variables 96 | garmin_email = os.getenv("GARMIN_EMAIL") 97 | garmin_password = os.getenv("GARMIN_PASSWORD") 98 | notion_token = os.getenv("NOTION_TOKEN") 99 | database_id = os.getenv("NOTION_STEPS_DB_ID") 100 | 101 | # Initialize Garmin client and login 102 | garmin = Garmin(garmin_email, garmin_password) 103 | garmin.login() 104 | client = Client(auth=notion_token) 105 | 106 | daily_steps = get_all_daily_steps(garmin) 107 | for steps in daily_steps: 108 | steps_date = steps.get('calendarDate') 109 | existing_steps = daily_steps_exist(client, database_id, steps_date) 110 | if existing_steps: 111 | if steps_need_update(existing_steps, steps): 112 | update_daily_steps(client, existing_steps, steps) 113 | else: 114 | create_daily_steps(client, database_id, steps) 115 | 116 | if __name__ == '__main__': 117 | main() 118 | -------------------------------------------------------------------------------- /garmin-activities.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from garminconnect import Garmin 3 | from notion_client import Client 4 | from dotenv import load_dotenv 5 | import pytz 6 | import os 7 | 8 | # Your local time zone, replace with the appropriate one if needed 9 | local_tz = pytz.timezone('America/Toronto') 10 | 11 | ACTIVITY_ICONS = { 12 | "Barre": "https://img.icons8.com/?size=100&id=66924&format=png&color=000000", 13 | "Breathwork": "https://img.icons8.com/?size=100&id=9798&format=png&color=000000", 14 | "Cardio": "https://img.icons8.com/?size=100&id=71221&format=png&color=000000", 15 | "Cycling": "https://img.icons8.com/?size=100&id=47443&format=png&color=000000", 16 | "Hiking": "https://img.icons8.com/?size=100&id=9844&format=png&color=000000", 17 | "Indoor Cardio": "https://img.icons8.com/?size=100&id=62779&format=png&color=000000", 18 | "Indoor Cycling": "https://img.icons8.com/?size=100&id=47443&format=png&color=000000", 19 | "Indoor Rowing": "https://img.icons8.com/?size=100&id=71098&format=png&color=000000", 20 | "Pilates": "https://img.icons8.com/?size=100&id=9774&format=png&color=000000", 21 | "Meditation": "https://img.icons8.com/?size=100&id=9798&format=png&color=000000", 22 | "Rowing": "https://img.icons8.com/?size=100&id=71491&format=png&color=000000", 23 | "Running": "https://img.icons8.com/?size=100&id=k1l1XFkME39t&format=png&color=000000", 24 | "Strength Training": "https://img.icons8.com/?size=100&id=107640&format=png&color=000000", 25 | "Stretching": "https://img.icons8.com/?size=100&id=djfOcRn1m_kh&format=png&color=000000", 26 | "Swimming": "https://img.icons8.com/?size=100&id=9777&format=png&color=000000", 27 | "Treadmill Running": "https://img.icons8.com/?size=100&id=9794&format=png&color=000000", 28 | "Walking": "https://img.icons8.com/?size=100&id=9807&format=png&color=000000", 29 | "Yoga": "https://img.icons8.com/?size=100&id=9783&format=png&color=000000", 30 | # Add more mappings as needed 31 | } 32 | 33 | def get_all_activities(garmin, limit=1000): 34 | return garmin.get_activities(0, limit) 35 | 36 | def format_activity_type(activity_type, activity_name=""): 37 | # First format the activity type as before 38 | formatted_type = activity_type.replace('_', ' ').title() if activity_type else "Unknown" 39 | 40 | # Initialize subtype as the same as the main type 41 | activity_subtype = formatted_type 42 | activity_type = formatted_type 43 | 44 | # Map of specific subtypes to their main types 45 | activity_mapping = { 46 | "Barre": "Strength", 47 | "Indoor Cardio": "Cardio", 48 | "Indoor Cycling": "Cycling", 49 | "Indoor Rowing": "Rowing", 50 | "Speed Walking": "Walking", 51 | "Strength Training": "Strength", 52 | "Treadmill Running": "Running" 53 | } 54 | 55 | # Special replacement for Rowing V2 56 | if formatted_type == "Rowing V2": 57 | activity_type = "Rowing" 58 | 59 | # Special case for Yoga and Pilates 60 | elif formatted_type in ["Yoga", "Pilates"]: 61 | activity_type = "Yoga/Pilates" 62 | activity_subtype = formatted_type 63 | 64 | # If the formatted type is in our mapping, update both main type and subtype 65 | if formatted_type in activity_mapping: 66 | activity_type = activity_mapping[formatted_type] 67 | activity_subtype = formatted_type 68 | 69 | # Special cases for activity names 70 | if activity_name and "meditation" in activity_name.lower(): 71 | return "Meditation", "Meditation" 72 | if activity_name and "barre" in activity_name.lower(): 73 | return "Strength", "Barre" 74 | if activity_name and "stretch" in activity_name.lower(): 75 | return "Stretching", "Stretching" 76 | 77 | return activity_type, activity_subtype 78 | 79 | def format_entertainment(activity_name): 80 | return activity_name.replace('ENTERTAINMENT', 'Netflix') 81 | 82 | def format_training_message(message): 83 | messages = { 84 | 'NO_': 'No Benefit', 85 | 'MINOR_': 'Some Benefit', 86 | 'RECOVERY_': 'Recovery', 87 | 'MAINTAINING_': 'Maintaining', 88 | 'IMPROVING_': 'Impacting', 89 | 'IMPACTING_': 'Impacting', 90 | 'HIGHLY_': 'Highly Impacting', 91 | 'OVERREACHING_': 'Overreaching' 92 | } 93 | for key, value in messages.items(): 94 | if message.startswith(key): 95 | return value 96 | return message 97 | 98 | def format_training_effect(trainingEffect_label): 99 | return trainingEffect_label.replace('_', ' ').title() 100 | 101 | def format_pace(average_speed): 102 | if average_speed > 0: 103 | pace_min_km = 1000 / (average_speed * 60) # Convert to min/km 104 | minutes = int(pace_min_km) 105 | seconds = int((pace_min_km - minutes) * 60) 106 | return f"{minutes}:{seconds:02d} min/km" 107 | else: 108 | return "" 109 | 110 | def activity_exists(client, database_id, activity_date, activity_type, activity_name): 111 | 112 | # Check if an activity already exists in the Notion database and return it if found. 113 | 114 | # Handle the activity_type which is now a tuple 115 | if isinstance(activity_type, tuple): 116 | main_type, _ = activity_type 117 | else: 118 | main_type = activity_type[0] if isinstance(activity_type, (list, tuple)) else activity_type 119 | 120 | # Determine the correct activity type for the lookup 121 | lookup_type = "Stretching" if "stretch" in activity_name.lower() else main_type 122 | 123 | query = client.databases.query( 124 | database_id=database_id, 125 | filter={ 126 | "and": [ 127 | {"property": "Date", "date": {"equals": activity_date.split('T')[0]}}, 128 | {"property": "Activity Type", "select": {"equals": lookup_type}}, 129 | {"property": "Activity Name", "title": {"equals": activity_name}} 130 | ] 131 | } 132 | ) 133 | results = query['results'] 134 | return results[0] if results else None 135 | 136 | 137 | def activity_needs_update(existing_activity, new_activity): 138 | existing_props = existing_activity['properties'] 139 | 140 | activity_name = new_activity.get('activityName', '').lower() 141 | activity_type, activity_subtype = format_activity_type( 142 | new_activity.get('activityType', {}).get('typeKey', 'Unknown'), 143 | activity_name 144 | ) 145 | 146 | # Check if 'Subactivity Type' property exists 147 | has_subactivity = ( 148 | 'Subactivity Type' in existing_props and 149 | existing_props['Subactivity Type'] is not None and 150 | existing_props['Subactivity Type'].get('select') is not None 151 | ) 152 | 153 | return ( 154 | existing_props['Distance (km)']['number'] != round(new_activity.get('distance', 0) / 1000, 2) or 155 | existing_props['Duration (min)']['number'] != round(new_activity.get('duration', 0) / 60, 2) or 156 | existing_props['Calories']['number'] != round(new_activity.get('calories', 0)) or 157 | existing_props['Avg Pace']['rich_text'][0]['text']['content'] != format_pace(new_activity.get('averageSpeed', 0)) or 158 | existing_props['Avg Power']['number'] != round(new_activity.get('avgPower', 0), 1) or 159 | existing_props['Max Power']['number'] != round(new_activity.get('maxPower', 0), 1) or 160 | existing_props['Training Effect']['select']['name'] != format_training_effect(new_activity.get('trainingEffectLabel', 'Unknown')) or 161 | existing_props['Aerobic']['number'] != round(new_activity.get('aerobicTrainingEffect', 0), 1) or 162 | existing_props['Aerobic Effect']['select']['name'] != format_training_message(new_activity.get('aerobicTrainingEffectMessage', 'Unknown')) or 163 | existing_props['Anaerobic']['number'] != round(new_activity.get('anaerobicTrainingEffect', 0), 1) or 164 | existing_props['Anaerobic Effect']['select']['name'] != format_training_message(new_activity.get('anaerobicTrainingEffectMessage', 'Unknown')) or 165 | existing_props['PR']['checkbox'] != new_activity.get('pr', False) or 166 | existing_props['Fav']['checkbox'] != new_activity.get('favorite', False) or 167 | existing_props['Activity Type']['select']['name'] != activity_type or 168 | (has_subactivity and existing_props['Subactivity Type']['select']['name'] != activity_subtype) or 169 | (not has_subactivity) # If the property doesn't exist, we need an update 170 | ) 171 | 172 | def create_activity(client, database_id, activity): 173 | 174 | # Create a new activity in the Notion database 175 | activity_date = activity.get('startTimeGMT') 176 | activity_name = format_entertainment(activity.get('activityName', 'Unnamed Activity')) 177 | activity_type, activity_subtype = format_activity_type( 178 | activity.get('activityType', {}).get('typeKey', 'Unknown'), 179 | activity_name 180 | ) 181 | 182 | # Get icon for the activity type 183 | icon_url = ACTIVITY_ICONS.get(activity_subtype if activity_subtype != activity_type else activity_type) 184 | 185 | properties = { 186 | "Date": {"date": {"start": activity_date}}, 187 | "Activity Type": {"select": {"name": activity_type}}, 188 | "Subactivity Type": {"select": {"name": activity_subtype}}, 189 | "Activity Name": {"title": [{"text": {"content": activity_name}}]}, 190 | "Distance (km)": {"number": round(activity.get('distance', 0) / 1000, 2)}, 191 | "Duration (min)": {"number": round(activity.get('duration', 0) / 60, 2)}, 192 | "Calories": {"number": round(activity.get('calories', 0))}, 193 | "Avg Pace": {"rich_text": [{"text": {"content": format_pace(activity.get('averageSpeed', 0))}}]}, 194 | "Avg Power": {"number": round(activity.get('avgPower', 0), 1)}, 195 | "Max Power": {"number": round(activity.get('maxPower', 0), 1)}, 196 | "Training Effect": {"select": {"name": format_training_effect(activity.get('trainingEffectLabel', 'Unknown'))}}, 197 | "Aerobic": {"number": round(activity.get('aerobicTrainingEffect', 0), 1)}, 198 | "Aerobic Effect": {"select": {"name": format_training_message(activity.get('aerobicTrainingEffectMessage', 'Unknown'))}}, 199 | "Anaerobic": {"number": round(activity.get('anaerobicTrainingEffect', 0), 1)}, 200 | "Anaerobic Effect": {"select": {"name": format_training_message(activity.get('anaerobicTrainingEffectMessage', 'Unknown'))}}, 201 | "PR": {"checkbox": activity.get('pr', False)}, 202 | "Fav": {"checkbox": activity.get('favorite', False)} 203 | } 204 | 205 | page = { 206 | "parent": {"database_id": database_id}, 207 | "properties": properties, 208 | } 209 | 210 | if icon_url: 211 | page["icon"] = {"type": "external", "external": {"url": icon_url}} 212 | 213 | client.pages.create(**page) 214 | 215 | def update_activity(client, existing_activity, new_activity): 216 | 217 | # Update an existing activity in the Notion database with new data 218 | activity_name = new_activity.get('activityName', 'Unnamed Activity') 219 | activity_type, activity_subtype = format_activity_type( 220 | new_activity.get('activityType', {}).get('typeKey', 'Unknown'), 221 | activity_name 222 | ) 223 | 224 | # Get icon for the activity type 225 | icon_url = ACTIVITY_ICONS.get(activity_subtype if activity_subtype != activity_type else activity_type) 226 | 227 | properties = { 228 | "Activity Type": {"select": {"name": activity_type}}, 229 | "Subactivity Type": {"select": {"name": activity_subtype}}, 230 | "Distance (km)": {"number": round(new_activity.get('distance', 0) / 1000, 2)}, 231 | "Duration (min)": {"number": round(new_activity.get('duration', 0) / 60, 2)}, 232 | "Calories": {"number": round(new_activity.get('calories', 0))}, 233 | "Avg Pace": {"rich_text": [{"text": {"content": format_pace(new_activity.get('averageSpeed', 0))}}]}, 234 | "Avg Power": {"number": round(new_activity.get('avgPower', 0), 1)}, 235 | "Max Power": {"number": round(new_activity.get('maxPower', 0), 1)}, 236 | "Training Effect": {"select": {"name": format_training_effect(new_activity.get('trainingEffectLabel', 'Unknown'))}}, 237 | "Aerobic": {"number": round(new_activity.get('aerobicTrainingEffect', 0), 1)}, 238 | "Aerobic Effect": {"select": {"name": format_training_message(new_activity.get('aerobicTrainingEffectMessage', 'Unknown'))}}, 239 | "Anaerobic": {"number": round(new_activity.get('anaerobicTrainingEffect', 0), 1)}, 240 | "Anaerobic Effect": {"select": {"name": format_training_message(new_activity.get('anaerobicTrainingEffectMessage', 'Unknown'))}}, 241 | "PR": {"checkbox": new_activity.get('pr', False)}, 242 | "Fav": {"checkbox": new_activity.get('favorite', False)} 243 | } 244 | 245 | update = { 246 | "page_id": existing_activity['id'], 247 | "properties": properties, 248 | } 249 | 250 | if icon_url: 251 | update["icon"] = {"type": "external", "external": {"url": icon_url}} 252 | 253 | client.pages.update(**update) 254 | 255 | def main(): 256 | load_dotenv() 257 | 258 | # Initialize Garmin and Notion clients using environment variables 259 | garmin_email = os.getenv("GARMIN_EMAIL") 260 | garmin_password = os.getenv("GARMIN_PASSWORD") 261 | notion_token = os.getenv("NOTION_TOKEN") 262 | database_id = os.getenv("NOTION_DB_ID") 263 | 264 | # Initialize Garmin client and login 265 | garmin = Garmin(garmin_email, garmin_password) 266 | garmin.login() 267 | client = Client(auth=notion_token) 268 | 269 | # Get all activities 270 | activities = get_all_activities(garmin) 271 | 272 | # Process all activities 273 | for activity in activities: 274 | activity_date = activity.get('startTimeGMT') 275 | activity_name = format_entertainment(activity.get('activityName', 'Unnamed Activity')) 276 | activity_type, activity_subtype = format_activity_type( 277 | activity.get('activityType', {}).get('typeKey', 'Unknown'), 278 | activity_name 279 | ) 280 | 281 | # Check if activity already exists in Notion 282 | existing_activity = activity_exists(client, database_id, activity_date, activity_type, activity_name) 283 | 284 | if existing_activity: 285 | if activity_needs_update(existing_activity, activity): 286 | update_activity(client, existing_activity, activity) 287 | # print(f"Updated: {activity_type} - {activity_name}") 288 | else: 289 | create_activity(client, database_id, activity) 290 | # print(f"Created: {activity_type} - {activity_name}") 291 | 292 | if __name__ == '__main__': 293 | main() -------------------------------------------------------------------------------- /personal-records.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | from garminconnect import Garmin 3 | from notion_client import Client 4 | import os 5 | 6 | def get_icon_for_record(activity_name): 7 | icon_map = { 8 | "1K": "🥇", 9 | "1mi": "⚡", 10 | "5K": "👟", 11 | "10K": "⭐", 12 | "Longest Run": "🏃", 13 | "Longest Ride": "🚴", 14 | "Total Ascent": "🚵", 15 | "Max Avg Power (20 min)": "🔋", 16 | "Most Steps in a Day": "👣", 17 | "Most Steps in a Week": "🚶", 18 | "Most Steps in a Month": "📅", 19 | "Longest Goal Streak": "✔️", 20 | "Other": "🏅" 21 | } 22 | return icon_map.get(activity_name, "🏅") # Default to "Other" icon if not found 23 | 24 | def get_cover_for_record(activity_name): 25 | cover_map = { 26 | "1K": "https://images.unsplash.com/photo-1526676537331-7747bf8278fc?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&w=4800", 27 | "1mi": "https://images.unsplash.com/photo-1638183395699-2c0db5b6afbb?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&w=4800", 28 | "5K": "https://images.unsplash.com/photo-1571008887538-b36bb32f4571?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&w=4800", 29 | "10K": "https://images.unsplash.com/photo-1529339944280-1a37d3d6fa8c?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&w=4800", 30 | "Longest Run": "https://images.unsplash.com/photo-1532383282788-19b341e3c422?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&w=4800", 31 | "Longest Ride": "https://images.unsplash.com/photo-1471506480208-91b3a4cc78be?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&w=4800", 32 | "Max Avg Power (20 min)": "https://images.unsplash.com/photo-1591741535018-d042766c62eb?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w2MzkyMXwwfDF8c2VhcmNofDJ8fHNwaW5uaW5nfGVufDB8fHx8MTcyNjM1Mzc0Mnww&ixlib=rb-4.0.3&q=80&w=4800", 33 | "Most Steps in a Day": "https://images.unsplash.com/photo-1476480862126-209bfaa8edc8?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&w=4800", 34 | "Most Steps in a Week": "https://images.unsplash.com/photo-1602174865963-9159ed37e8f1?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&w=4800", 35 | "Most Steps in a Month": "https://images.unsplash.com/photo-1580058572462-98e2c0e0e2f0?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&w=4800", 36 | "Longest Goal Streak": "https://images.unsplash.com/photo-1477332552946-cfb384aeaf1c?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&w=4800" 37 | } 38 | return cover_map.get(activity_name, "https://images.unsplash.com/photo-1471506480208-91b3a4cc78be?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&w=4800") 39 | 40 | def format_activity_type(activity_type): 41 | if activity_type is None: 42 | return "Walking" 43 | return activity_type.replace('_', ' ').title() 44 | 45 | def format_activity_name(activity_name): 46 | if not activity_name or activity_name is None: 47 | return "Unnamed Activity" 48 | return activity_name 49 | 50 | def format_garmin_value(value, activity_type, typeId): 51 | if typeId == 1: # 1K 52 | total_seconds = round(value) # Round to the nearest second 53 | minutes = total_seconds // 60 54 | seconds = total_seconds % 60 55 | formatted_value = f"{minutes}:{seconds:02d} /km" 56 | pace = formatted_value # For these types, the value is the pace 57 | return formatted_value, pace 58 | 59 | if typeId == 2: # 1mile 60 | total_seconds = round(value) # Round to the nearest second 61 | minutes = total_seconds // 60 62 | seconds = total_seconds % 60 63 | formatted_value = f"{minutes}:{seconds:02d}" 64 | total_pseconds = total_seconds / 1.60934 # Divide by 1.60934 to get pace per km 65 | pminutes = int(total_pseconds // 60) # Convert to integer 66 | pseconds = int(total_pseconds % 60) # Convert to integer 67 | formatted_pace = f"{pminutes}:{pseconds:02d} /km" 68 | return formatted_value, formatted_pace 69 | 70 | if typeId == 3: # 5K 71 | total_seconds = round(value) 72 | minutes = total_seconds // 60 73 | seconds = total_seconds % 60 74 | formatted_value = f"{minutes}:{seconds:02d}" 75 | total_pseconds = total_seconds // 5 # Divide by 5km 76 | pminutes = total_pseconds // 60 77 | pseconds = total_pseconds % 60 78 | formatted_pace = f"{pminutes}:{pseconds:02d} /km" 79 | return formatted_value, formatted_pace 80 | 81 | if typeId == 4: # 10K 82 | # Round to the nearest second 83 | total_seconds = round(value) 84 | hours = total_seconds // 3600 85 | minutes = (total_seconds % 3600) // 60 86 | seconds = total_seconds % 60 87 | if hours > 0: 88 | formatted_value = f"{hours}:{minutes:02d}:{seconds:02d}" 89 | else: 90 | formatted_value = f"{minutes}:{seconds:02d}" 91 | total_pseconds = total_seconds // 10 # Divide by 10km 92 | phours = total_pseconds // 3600 93 | pminutes = (total_pseconds % 3600) // 60 94 | pseconds = total_pseconds % 60 95 | formatted_pace = f"{pminutes}:{pseconds:02d} /km" 96 | return formatted_value, formatted_pace 97 | 98 | if typeId in [7, 8]: # Longest Run, Longest Ride 99 | value_km = value / 1000 100 | formatted_value = f"{value_km:.2f} km" 101 | pace = "" # No pace for these types 102 | return formatted_value, pace 103 | 104 | if typeId == 9: # Total Ascent 105 | value_m = int(value) 106 | formatted_value = f"{value_m:,} m" 107 | pace = "" 108 | return formatted_value, pace 109 | 110 | if typeId == 10: # Max Avg Power 111 | value_w = round(value) 112 | formatted_value = f"{value_w} W" 113 | pace = "" 114 | return formatted_value, pace 115 | 116 | if typeId in [12, 13, 14]: # Step counts 117 | value_steps = round(value) 118 | formatted_value = f"{value_steps:,}" 119 | pace = "" 120 | return formatted_value, pace 121 | 122 | if typeId == 15: # Longest Goal Streak 123 | value_days = round(value) 124 | formatted_value = f"{value_days} days" 125 | pace = "" 126 | return formatted_value, pace 127 | 128 | # Default case 129 | if int(value // 60) < 60: # If total time is less than an hour 130 | minutes = int(value // 60) 131 | seconds = round((value / 60 - minutes) * 60, 2) 132 | formatted_value = f"{minutes}:{seconds:05.2f}" 133 | else: # If total time is one hour or more 134 | hours = int(value // 3600) 135 | minutes = int((value % 3600) // 60) 136 | seconds = round(value % 60, 2) 137 | formatted_value = f"{hours}:{minutes:02}:{seconds:05.2f}" 138 | 139 | pace = "" 140 | return formatted_value, pace 141 | 142 | def replace_activity_name_by_typeId(typeId): 143 | typeId_name_map = { 144 | 1: "1K", 145 | 2: "1mi", 146 | 3: "5K", 147 | 4: "10K", 148 | 7: "Longest Run", 149 | 8: "Longest Ride", 150 | 9: "Total Ascent", 151 | 10: "Max Avg Power (20 min)", 152 | 12: "Most Steps in a Day", 153 | 13: "Most Steps in a Week", 154 | 14: "Most Steps in a Month", 155 | 15: "Longest Goal Streak" 156 | } 157 | return typeId_name_map.get(typeId, "Unnamed Activity") 158 | 159 | def get_existing_record(client, database_id, activity_name): 160 | query = client.databases.query( 161 | database_id=database_id, 162 | filter={ 163 | "and": [ 164 | {"property": "Record", "title": {"equals": activity_name}}, 165 | {"property": "PR", "checkbox": {"equals": True}} 166 | ] 167 | } 168 | ) 169 | return query['results'][0] if query['results'] else None 170 | 171 | def get_record_by_date_and_name(client, database_id, activity_date, activity_name): 172 | query = client.databases.query( 173 | database_id=database_id, 174 | filter={ 175 | "and": [ 176 | {"property": "Record", "title": {"equals": activity_name}}, 177 | {"property": "Date", "date": {"equals": activity_date}} 178 | ] 179 | } 180 | ) 181 | return query['results'][0] if query['results'] else None 182 | 183 | def update_record(client, page_id, activity_date, value, pace, activity_name, is_pr=True): 184 | properties = { 185 | "Date": {"date": {"start": activity_date}}, 186 | "PR": {"checkbox": is_pr} 187 | } 188 | 189 | if value: 190 | properties["Value"] = {"rich_text": [{"text": {"content": value}}]} 191 | 192 | if pace: 193 | properties["Pace"] = {"rich_text": [{"text": {"content": pace}}]} 194 | 195 | icon = get_icon_for_record(activity_name) 196 | cover = get_cover_for_record(activity_name) 197 | 198 | try: 199 | client.pages.update( 200 | page_id=page_id, 201 | properties=properties, 202 | icon={"emoji": icon}, 203 | cover={"type": "external", "external": {"url": cover}} 204 | ) 205 | 206 | except Exception as e: 207 | print(f"Error updating record: {e}") 208 | 209 | def write_new_record(client, database_id, activity_date, activity_type, activity_name, typeId, value, pace): 210 | properties = { 211 | "Date": {"date": {"start": activity_date}}, 212 | "Activity Type": {"select": {"name": activity_type}}, 213 | "Record": {"title": [{"text": {"content": activity_name}}]}, 214 | "typeId": {"number": typeId}, 215 | "PR": {"checkbox": True} 216 | } 217 | 218 | if value: 219 | properties["Value"] = {"rich_text": [{"text": {"content": value}}]} 220 | 221 | if pace: 222 | properties["Pace"] = {"rich_text": [{"text": {"content": pace}}]} 223 | 224 | icon = get_icon_for_record(activity_name) 225 | cover = get_cover_for_record(activity_name) 226 | 227 | try: 228 | client.pages.create( 229 | parent={"database_id": database_id}, 230 | properties=properties, 231 | icon={"emoji": icon}, 232 | cover={"type": "external", "external": {"url": cover}} 233 | ) 234 | except Exception as e: 235 | print(f"Error writing new record: {e}") 236 | 237 | def main(): 238 | garmin_email = os.getenv("GARMIN_EMAIL") 239 | garmin_password = os.getenv("GARMIN_PASSWORD") 240 | notion_token = os.getenv("NOTION_TOKEN") 241 | database_id = os.getenv("NOTION_PR_DB_ID") 242 | 243 | garmin = Garmin(garmin_email, garmin_password) 244 | garmin.login() 245 | 246 | client = Client(auth=notion_token) 247 | 248 | records = garmin.get_personal_record() 249 | filtered_records = [record for record in records if record.get('typeId') != 16] 250 | 251 | for record in filtered_records: 252 | activity_date = record.get('prStartTimeGmtFormatted') 253 | activity_type = format_activity_type(record.get('activityType')) 254 | activity_name = replace_activity_name_by_typeId(record.get('typeId')) 255 | typeId = record.get('typeId', 0) 256 | value, pace = format_garmin_value(record.get('value', 0), activity_type, typeId) 257 | 258 | existing_pr_record = get_existing_record(client, database_id, activity_name) 259 | existing_date_record = get_record_by_date_and_name(client, database_id, activity_date, activity_name) 260 | 261 | if existing_date_record: 262 | update_record(client, existing_date_record['id'], activity_date, value, pace, activity_name, True) 263 | print(f"Updated existing record: {activity_type} - {activity_name}") 264 | elif existing_pr_record: 265 | # Add error handling here 266 | try: 267 | date_prop = existing_pr_record['properties']['Date'] 268 | if date_prop and date_prop.get('date') and date_prop['date'].get('start'): 269 | existing_date = date_prop['date']['start'] 270 | 271 | if activity_date > existing_date: 272 | update_record(client, existing_pr_record['id'], existing_date, None, None, activity_name, False) 273 | print(f"Archived old record: {activity_type} - {activity_name}") 274 | 275 | write_new_record(client, database_id, activity_date, activity_type, activity_name, typeId, value, pace) 276 | print(f"Created new PR record: {activity_type} - {activity_name}") 277 | else: 278 | print(f"No update needed: {activity_type} - {activity_name}") 279 | else: 280 | # Handle case where date is missing or improperly formatted 281 | print(f"Warning: Record {activity_name} has invalid date format - updating anyway") 282 | update_record(client, existing_pr_record['id'], activity_date, value, pace, activity_name, True) 283 | except (KeyError, TypeError) as e: 284 | print(f"Error processing record {activity_name}: {e}") 285 | print(f"Record data: {existing_pr_record['properties']}") 286 | # Fallback - create new record if we can't process the existing one properly 287 | write_new_record(client, database_id, activity_date, activity_type, activity_name, typeId, value, pace) 288 | else: 289 | write_new_record(client, database_id, activity_date, activity_type, activity_name, typeId, value, pace) 290 | print(f"Successfully written new record: {activity_type} - {activity_name}") 291 | 292 | if __name__ == '__main__': 293 | main() 294 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | garminconnect>=0.2.19,<0.3 2 | notion-client==2.2.1 3 | pytz==2024.1 4 | datetime==5.5 5 | withings-sync==4.2.4 6 | lxml>=4.6.0,<5.0 7 | 8 | 9 | -------------------------------------------------------------------------------- /sleep-data.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from garminconnect import Garmin 3 | from notion_client import Client 4 | from dotenv import load_dotenv, dotenv_values 5 | import pytz 6 | import os 7 | 8 | # Constants 9 | local_tz = pytz.timezone("America/New_York") 10 | 11 | # Load environment variables 12 | load_dotenv() 13 | CONFIG = dotenv_values() 14 | 15 | def get_sleep_data(garmin): 16 | today = datetime.today().date() 17 | return garmin.get_sleep_data(today.isoformat()) 18 | 19 | def format_duration(seconds): 20 | minutes = (seconds or 0) // 60 21 | return f"{minutes // 60}h {minutes % 60}m" 22 | 23 | def format_time(timestamp): 24 | return ( 25 | datetime.utcfromtimestamp(timestamp / 1000).strftime("%Y-%m-%dT%H:%M:%S.000Z") 26 | if timestamp else None 27 | ) 28 | 29 | def format_time_readable(timestamp): 30 | return ( 31 | datetime.fromtimestamp(timestamp / 1000, local_tz).strftime("%H:%M") 32 | if timestamp else "Unknown" 33 | ) 34 | 35 | def format_date_for_name(sleep_date): 36 | return datetime.strptime(sleep_date, "%Y-%m-%d").strftime("%d.%m.%Y") if sleep_date else "Unknown" 37 | 38 | def sleep_data_exists(client, database_id, sleep_date): 39 | query = client.databases.query( 40 | database_id=database_id, 41 | filter={"property": "Long Date", "date": {"equals": sleep_date}} 42 | ) 43 | results = query.get('results', []) 44 | return results[0] if results else None # Ensure it returns None instead of causing IndexError 45 | 46 | def create_sleep_data(client, database_id, sleep_data, skip_zero_sleep=True): 47 | daily_sleep = sleep_data.get('dailySleepDTO', {}) 48 | if not daily_sleep: 49 | return 50 | 51 | sleep_date = daily_sleep.get('calendarDate', "Unknown Date") 52 | total_sleep = sum( 53 | (daily_sleep.get(k, 0) or 0) for k in ['deepSleepSeconds', 'lightSleepSeconds', 'remSleepSeconds'] 54 | ) 55 | 56 | 57 | if skip_zero_sleep and total_sleep == 0: 58 | print(f"Skipping sleep data for {sleep_date} as total sleep is 0") 59 | return 60 | 61 | properties = { 62 | "Date": {"title": [{"text": {"content": format_date_for_name(sleep_date)}}]}, 63 | "Times": {"rich_text": [{"text": {"content": f"{format_time_readable(daily_sleep.get('sleepStartTimestampGMT'))} → {format_time_readable(daily_sleep.get('sleepEndTimestampGMT'))}"}}]}, 64 | "Long Date": {"date": {"start": sleep_date}}, 65 | "Full Date/Time": {"date": {"start": format_time(daily_sleep.get('sleepStartTimestampGMT')), "end": format_time(daily_sleep.get('sleepEndTimestampGMT'))}}, 66 | "Total Sleep (h)": {"number": round(total_sleep / 3600, 1)}, 67 | "Light Sleep (h)": {"number": round(daily_sleep.get('lightSleepSeconds', 0) / 3600, 1)}, 68 | "Deep Sleep (h)": {"number": round(daily_sleep.get('deepSleepSeconds', 0) / 3600, 1)}, 69 | "REM Sleep (h)": {"number": round(daily_sleep.get('remSleepSeconds', 0) / 3600, 1)}, 70 | "Awake Time (h)": {"number": round(daily_sleep.get('awakeSleepSeconds', 0) / 3600, 1)}, 71 | "Total Sleep": {"rich_text": [{"text": {"content": format_duration(total_sleep)}}]}, 72 | "Light Sleep": {"rich_text": [{"text": {"content": format_duration(daily_sleep.get('lightSleepSeconds', 0))}}]}, 73 | "Deep Sleep": {"rich_text": [{"text": {"content": format_duration(daily_sleep.get('deepSleepSeconds', 0))}}]}, 74 | "REM Sleep": {"rich_text": [{"text": {"content": format_duration(daily_sleep.get('remSleepSeconds', 0))}}]}, 75 | "Awake Time": {"rich_text": [{"text": {"content": format_duration(daily_sleep.get('awakeSleepSeconds', 0))}}]}, 76 | "Resting HR": {"number": sleep_data.get('restingHeartRate', 0)} 77 | } 78 | 79 | client.pages.create(parent={"database_id": database_id}, properties=properties, icon={"emoji": "😴"}) 80 | print(f"Created sleep entry for: {sleep_date}") 81 | 82 | def main(): 83 | load_dotenv() 84 | 85 | # Initialize Garmin and Notion clients using environment variables 86 | garmin_email = os.getenv("GARMIN_EMAIL") 87 | garmin_password = os.getenv("GARMIN_PASSWORD") 88 | notion_token = os.getenv("NOTION_TOKEN") 89 | database_id = os.getenv("NOTION_SLEEP_DB_ID") 90 | 91 | # Initialize Garmin client and login 92 | garmin = Garmin(garmin_email, garmin_password) 93 | garmin.login() 94 | client = Client(auth=notion_token) 95 | 96 | data = get_sleep_data(garmin) 97 | if data: 98 | sleep_date = data.get('dailySleepDTO', {}).get('calendarDate') 99 | if sleep_date and not sleep_data_exists(client, database_id, sleep_date): 100 | create_sleep_data(client, database_id, data, skip_zero_sleep=True) 101 | 102 | if __name__ == '__main__': 103 | main() 104 | --------------------------------------------------------------------------------