Analyze data from Apple’s calendar

New automation alert
calendar
python
github
automation
Author

Lindsay Lee

Published

May 26, 2025

I regularly have to count up hours for different events in my calendar, which is a bit tedious. I thought it would be nice to automate this calculation, so with Claude’s help, I wrote a Python script that reads the calendar data from Apple’s Calendar app and counts up the hours for each event. I pushed this script to a GitHub repository and created a GitHub Actions workflow to run every week and display the totals in the Actions summary view. My GitHub repo for this is private, but I’ll show the important pieces here.

The packages I use are as follows:

import caldav
from datetime import datetime, timedelta
import pytz
import os
from urllib.parse import quote
import logging

The key package is caldav, which is what allows you to connect to the calendar.

I set up a conda environment with the following environment.yml file:

name: calendar-automation
channels:
  - conda-forge
  - defaults
dependencies:
  - python=3.11
  - pip=23.2.1
  - caldav=1.3.9
  - pytz=2025.2

I created a function to connect to the calendar as follows:

def connect_to_icloud_calendar():
    """Connect to iCloud calendar via CalDAV"""
    
    # Get credentials from environment variables
    username = os.getenv('ICLOUD_USERNAME')  # your Apple ID
    password = os.getenv('ICLOUD_APP_PASSWORD')  # App-specific password
    
    if not username or not password:
        raise ValueError("Missing iCloud credentials. Set ICLOUD_USERNAME and ICLOUD_APP_PASSWORD environment variables.")
    
    # iCloud CalDAV URL
    url = f"https://{quote(username)}:{quote(password)}@caldav.icloud.com"
    
    try:
        client = caldav.DAVClient(url)
        principal = client.principal()
        calendars = principal.calendars()
        
        if not calendars:
            raise Exception("No calendars found")
            
        print(f"Connected to iCloud calendar successfully")
        return calendars
        
    except Exception as e:
        print(f"Failed to connect to iCloud calendar: {e}")
        raise

This function depends on two environment variables: ICLOUD_USERNAME and ICLOUD_APP_PASSWORD. You can set these variables locally by running this after you have activated your conda environment:

export ICLOUD_USERNAME="your-apple-id@email.com"
export ICLOUD_APP_PASSWORD="your-app-specific-password"

The app-specific password can be generated by going to appleid.apple.com, logging in, going to the “Sign-In and Security” section, then clicking “App-Specific Passwords”. Follow the instructions to generate an app-specific password. It’ll only show it to you once. You may want to keep a note of it in a password manager so you can use it also for GitHub Actions.

You then connect to the calendar by calling the function:

calendars = connect_to_icloud_calendar()

Next, I wrote a function to get the events from the calendar:

def get_work_week_events(calendars, event_name, start_date, end_date):
    """Get events within a date range"""
    
    print(f"Searching for '{event_name}' events from {start_date.date()} ({start_date.strftime('%A')}) to {end_date.date()} ({end_date.strftime('%A')})")
    print("-" * 60)
    
    matching_events = []
    total_duration = timedelta(0)
    
    # Search through all calendars
    for calendar in calendars:
        try:
            # Fetch events in date range
            events = calendar.date_search(start=start_date, end=end_date)
            
            for event in events:
                # Parse the event data
                event_data = event.icalendar_component
                
                summary = str(event_data.get('SUMMARY', ''))
                
                # Check if event name matches (case insensitive)
                if event_name.lower() not in summary.lower():
                    continue
                
                # Get event times
                start_time = event_data.get('DTSTART').dt
                end_time = event_data.get('DTEND').dt
                
                # Handle all-day events (date objects vs datetime objects)
                if hasattr(start_time, 'hour'):  # It's a datetime
                    # Convert to UTC if needed
                    if hasattr(start_time, 'tzinfo') and start_time.tzinfo:
                        if start_time.tzinfo != pytz.UTC:
                            start_time = start_time.astimezone(pytz.UTC)
                    else:
                        start_time = pytz.UTC.localize(start_time)
                        
                    if hasattr(end_time, 'tzinfo') and end_time.tzinfo:
                        if end_time.tzinfo != pytz.UTC:
                            end_time = end_time.astimezone(pytz.UTC)
                    else:
                        end_time = pytz.UTC.localize(end_time)
                else:  # It's an all-day event (date object)
                    # Convert date to datetime at start/end of day
                    start_time = datetime.combine(start_time, datetime.min.time())
                    end_time = datetime.combine(end_time, datetime.min.time())
                    start_time = pytz.UTC.localize(start_time)
                    end_time = pytz.UTC.localize(end_time)
                
                # Calculate duration
                duration = end_time - start_time
                
                matching_events.append({
                    'summary': summary,
                    'start': start_time,
                    'end': end_time,
                    'duration': duration
                })
                total_duration += duration
                
        except Exception as e:
            print(f"Error processing calendar {calendar}: {e}")
            continue
    
    # Sort events by start time
    matching_events.sort(key=lambda x: x['start'])
    
    return matching_events, total_duration

This function takes the calendars data object, the name of the event to search for event_name, and a date range start_date and end_date. It returns a list of matching events and the total duration of those events. It is called like this:

events, total_duration = get_work_week_events(calendars, event_name, start_date, end_date)

Next I do some processing and formatting of the results, which print to the console when run locally.

After confirming it all works as expected locally, I set up GitHub Actions by creating a workflow file at .github/workflows/weekly-calendar-report.yml:

name: Weekly Calendar Report

on:
  schedule:
    # Run every Sunday at 9 AM UTC 
    - cron: '0 9 * * 0'  # 0 = Sunday
  workflow_dispatch: # Allow manual trigger for testing

jobs:
  calendar-report:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
      
    - name: Setup Conda Environment
      uses: conda-incubator/setup-miniconda@v3
      with:
        environment-file: environment.yml
        activate-environment: calendar-automation
        python-version: 3.11
        auto-activate-base: false
        auto-update-conda: true

    - name: Check conda environment
      shell: bash -el {0}  # This ensures conda activation works
      run: | 
        conda info
        conda list
        which python
        python --version

    - name: Generate Calendar Report
      shell: bash -el {0}  
      env:
        ICLOUD_USERNAME: ${{ secrets.ICLOUD_USERNAME }}
        ICLOUD_APP_PASSWORD: ${{ secrets.ICLOUD_APP_PASSWORD }}
        EVENT_NAME: ${{ secrets.EVENT_NAME }}
      run: |
        echo "=== WEEKLY CALENDAR REPORT ==="
        echo "Generated: $(date)"
        echo "Workflow Run: ${{ github.run_id }}"
        echo ""
        echo "Active conda environment: $CONDA_DEFAULT_ENV"
        
        # Run the script and capture output
        python calendar_report.py > calendar_output.txt 2>&1
        
        # Display in logs
        cat calendar_output.txt
        
        # Add to GitHub workflow summary
        echo "## 📅 Weekly Calendar Report" >> $GITHUB_STEP_SUMMARY
        echo "**Generated:** $(date)" >> $GITHUB_STEP_SUMMARY
        echo "" >> $GITHUB_STEP_SUMMARY
        echo "### Calendar Output:" >> $GITHUB_STEP_SUMMARY
        echo '```' >> $GITHUB_STEP_SUMMARY
        cat calendar_output.txt >> $GITHUB_STEP_SUMMARY
        echo '```' >> $GITHUB_STEP_SUMMARY
        
        echo ""
        echo "=== END REPORT ==="

In the Generate Calendar Report step, I run the Python script calendar_report.py which contains all the code to connect to the calendar, find the right events, do the analyses, and print the results. The output is captured in a temp file calendar_output.txt, which is then displayed in the GitHub Actions logs and added to the workflow summary by echoing the output to $GITHUB_STEP_SUMMARY.

The environmental variables ICLOUD_USERNAME, ICLOUD_APP_PASSWORD, and EVENT_NAME are set as GitHub Secrets, which you can configure in your repository settings under “Settings” -> “Secrets and variables” -> “Actions” -> “Repository secrets”. EVENT_NAME is a pipe-delimited string of event names you want to search for, e.g. Work|Meeting|Project. In my full script I set it up where it searches for all event names included in this string.

That’s it! Quite a bit of work to save me about 2 minutes each week, but it’ll be worth it eventually.