Basic Calendar App
Abstract
A calendar app is one of the most useful pieces of software ever built, and surprisingly approachable in Python. In this project you create a command-line calendar with event creation, categories, monthly views, search, statistics, and JSON persistence. Then we layer on the features that turn a toy into a tool: recurring events, desktop reminders, ICS export (the universal calendar format), and a Tkinter GUI with a real grid view.
You will learn:
- Python’s
datetimedatetimeandcalendarcalendarmodules. - How to build an ASCII monthly calendar with event indicators.
- The data model used by every calendar app (RFC 5545 / iCalendar).
- How recurring events work (RRULE).
- How to export to
.ics.icsso events sync to Google Calendar / Apple Calendar.
Prerequisites
- Python 3.6 or above.
- A text editor or IDE.
- Comfort with classes, dictionaries, and
datetimedatetimebasics. - Familiarity with Personal Diary for the same JSON-persistence shape.
Getting Started
Create the project
- Create folder
calendar-appcalendar-app. - Inside, create
basiccalendarapp.pybasiccalendarapp.py.
Write the code
Calendar App
Source# Basic Calendar App
import calendar
import json
import os
from datetime import datetime, date, timedelta
from typing import Dict, List, Optional, Tuple
class Event:
def __init__(self, event_id: str, title: str, date: str, time: str = "",
description: str = "", category: str = "General"):
self.id = event_id
self.title = title
self.date = date # Format: YYYY-MM-DD
self.time = time # Format: HH:MM
self.description = description
self.category = category
self.created_at = datetime.now().isoformat()
def to_dict(self) -> Dict:
"""Convert event to dictionary"""
return {
'id': self.id,
'title': self.title,
'date': self.date,
'time': self.time,
'description': self.description,
'category': self.category,
'created_at': self.created_at
}
@classmethod
def from_dict(cls, data: Dict) -> 'Event':
"""Create event from dictionary"""
event = cls(
data['id'], data['title'], data['date'],
data.get('time', ''), data.get('description', ''),
data.get('category', 'General')
)
event.created_at = data.get('created_at', datetime.now().isoformat())
return event
def get_datetime(self) -> Optional[datetime]:
"""Get datetime object for the event"""
try:
if self.time:
datetime_str = f"{self.date} {self.time}"
return datetime.strptime(datetime_str, "%Y-%m-%d %H:%M")
else:
return datetime.strptime(self.date, "%Y-%m-%d")
except ValueError:
return None
def is_today(self) -> bool:
"""Check if event is today"""
return self.date == date.today().strftime("%Y-%m-%d")
def is_upcoming(self, days: int = 7) -> bool:
"""Check if event is within next N days"""
try:
event_date = datetime.strptime(self.date, "%Y-%m-%d").date()
today = date.today()
return today <= event_date <= today + timedelta(days=days)
except ValueError:
return False
def __str__(self):
time_str = f" at {self.time}" if self.time else ""
return f"{self.title}{time_str} ({self.category})"
class BasicCalendarApp:
def __init__(self, data_file: str = "calendar_data.json"):
self.data_file = data_file
self.events = {} # event_id -> Event
self.categories = set(["General", "Work", "Personal", "Health", "Education"])
self.load_data()
def load_data(self):
"""Load events from JSON file"""
if os.path.exists(self.data_file):
try:
with open(self.data_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# Load events
for event_data in data.get('events', []):
event = Event.from_dict(event_data)
self.events[event.id] = event
# Load categories
saved_categories = data.get('categories', [])
if saved_categories:
self.categories.update(saved_categories)
except (json.JSONDecodeError, KeyError) as e:
print(f"Error loading data: {e}")
self.events = {}
def save_data(self):
"""Save events to JSON file"""
try:
data = {
'events': [event.to_dict() for event in self.events.values()],
'categories': list(self.categories)
}
with open(self.data_file, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Error saving data: {e}")
def generate_event_id(self) -> str:
"""Generate unique event ID"""
if not self.events:
return "EVT001"
# Extract numeric part and increment
max_num = 0
for event_id in self.events.keys():
if event_id.startswith("EVT"):
try:
num = int(event_id[3:])
max_num = max(max_num, num)
except ValueError:
pass
return f"EVT{max_num + 1:03d}"
def add_event(self, title: str, date_str: str, time_str: str = "",
description: str = "", category: str = "General") -> Optional[Event]:
"""Add a new event"""
try:
# Validate date format
datetime.strptime(date_str, "%Y-%m-%d")
# Validate time format if provided
if time_str:
datetime.strptime(time_str, "%H:%M")
event_id = self.generate_event_id()
event = Event(event_id, title, date_str, time_str, description, category)
self.events[event_id] = event
# Add category if new
self.categories.add(category)
self.save_data()
return event
except ValueError as e:
print(f"Invalid date/time format: {e}")
return None
def get_event(self, event_id: str) -> Optional[Event]:
"""Get event by ID"""
return self.events.get(event_id)
def update_event(self, event_id: str, title: str = None, date_str: str = None,
time_str: str = None, description: str = None,
category: str = None) -> bool:
"""Update an existing event"""
event = self.get_event(event_id)
if not event:
return False
try:
if title is not None:
event.title = title
if date_str is not None:
datetime.strptime(date_str, "%Y-%m-%d") # Validate
event.date = date_str
if time_str is not None:
if time_str: # If not empty, validate
datetime.strptime(time_str, "%H:%M")
event.time = time_str
if description is not None:
event.description = description
if category is not None:
event.category = category
self.categories.add(category)
self.save_data()
return True
except ValueError as e:
print(f"Invalid date/time format: {e}")
return False
def delete_event(self, event_id: str) -> bool:
"""Delete an event"""
if event_id in self.events:
del self.events[event_id]
self.save_data()
return True
return False
def get_events_for_date(self, date_str: str) -> List[Event]:
"""Get all events for a specific date"""
return [event for event in self.events.values() if event.date == date_str]
def get_events_for_month(self, year: int, month: int) -> List[Event]:
"""Get all events for a specific month"""
month_events = []
for event in self.events.values():
try:
event_date = datetime.strptime(event.date, "%Y-%m-%d")
if event_date.year == year and event_date.month == month:
month_events.append(event)
except ValueError:
continue
# Sort by date and time
month_events.sort(key=lambda e: (e.date, e.time or "00:00"))
return month_events
def get_upcoming_events(self, days: int = 7) -> List[Event]:
"""Get upcoming events within N days"""
upcoming = [event for event in self.events.values() if event.is_upcoming(days)]
# Sort by date and time
upcoming.sort(key=lambda e: (e.date, e.time or "00:00"))
return upcoming
def get_today_events(self) -> List[Event]:
"""Get today's events"""
today_str = date.today().strftime("%Y-%m-%d")
today_events = self.get_events_for_date(today_str)
# Sort by time
today_events.sort(key=lambda e: e.time or "00:00")
return today_events
def get_events_by_category(self, category: str) -> List[Event]:
"""Get all events in a specific category"""
return [event for event in self.events.values() if event.category == category]
def search_events(self, query: str) -> List[Event]:
"""Search events by title or description"""
query = query.lower()
results = []
for event in self.events.values():
if (query in event.title.lower() or
query in event.description.lower() or
query in event.category.lower()):
results.append(event)
# Sort by date
results.sort(key=lambda e: e.date)
return results
def get_calendar_view(self, year: int, month: int) -> str:
"""Get calendar view for a month with events"""
cal = calendar.monthcalendar(year, month)
month_name = calendar.month_name[month]
# Get events for the month
month_events = self.get_events_for_month(year, month)
events_by_day = {}
for event in month_events:
try:
day = datetime.strptime(event.date, "%Y-%m-%d").day
if day not in events_by_day:
events_by_day[day] = []
events_by_day[day].append(event)
except ValueError:
continue
# Create calendar string
result = f"\n{month_name} {year}\n"
result += "Mo Tu We Th Fr Sa Su\n"
for week in cal:
week_str = ""
for day in week:
if day == 0:
week_str += " "
else:
day_str = f"{day:2d}"
# Mark days with events
if day in events_by_day:
day_str += "*"
else:
day_str += " "
week_str += day_str
result += week_str + "\n"
result += "\n* = Has events\n"
return result
def get_event_statistics(self) -> Dict:
"""Get event statistics"""
if not self.events:
return {}
total_events = len(self.events)
# Category distribution
category_count = {}
for event in self.events.values():
category_count[event.category] = category_count.get(event.category, 0) + 1
# Upcoming events
upcoming_count = len(self.get_upcoming_events(30))
today_count = len(self.get_today_events())
# Monthly distribution
monthly_count = {}
for event in self.events.values():
try:
event_date = datetime.strptime(event.date, "%Y-%m-%d")
month_key = f"{event_date.year}-{event_date.month:02d}"
monthly_count[month_key] = monthly_count.get(month_key, 0) + 1
except ValueError:
continue
return {
'total_events': total_events,
'today_events': today_count,
'upcoming_events': upcoming_count,
'categories': dict(category_count),
'monthly_distribution': dict(monthly_count),
'total_categories': len(self.categories)
}
def export_events(self, filename: str, start_date: str = None, end_date: str = None):
"""Export events to a text file"""
try:
events_to_export = list(self.events.values())
# Filter by date range if provided
if start_date:
events_to_export = [e for e in events_to_export if e.date >= start_date]
if end_date:
events_to_export = [e for e in events_to_export if e.date <= end_date]
# Sort by date
events_to_export.sort(key=lambda e: (e.date, e.time or "00:00"))
with open(filename, 'w', encoding='utf-8') as f:
f.write("Calendar Events Export\n")
f.write("=" * 50 + "\n\n")
current_date = None
for event in events_to_export:
if event.date != current_date:
current_date = event.date
f.write(f"\n{current_date} ({calendar.day_name[datetime.strptime(current_date, '%Y-%m-%d').weekday()]})\n")
f.write("-" * 30 + "\n")
time_str = f" at {event.time}" if event.time else ""
f.write(f"• {event.title}{time_str}\n")
if event.description:
f.write(f" Description: {event.description}\n")
f.write(f" Category: {event.category}\n\n")
print(f"Events exported to {filename}")
except Exception as e:
print(f"Error exporting events: {e}")
def add_category(self, category: str):
"""Add a new category"""
self.categories.add(category)
self.save_data()
def display_events(events: List[Event], title: str = "Events"):
"""Display a list of events"""
print(f"\n=== {title} ===")
if not events:
print("No events found.")
return
current_date = None
for event in events:
if event.date != current_date:
current_date = event.date
try:
date_obj = datetime.strptime(current_date, "%Y-%m-%d")
day_name = calendar.day_name[date_obj.weekday()]
print(f"\n{current_date} ({day_name})")
print("-" * 25)
except ValueError:
print(f"\n{current_date}")
print("-" * 25)
time_str = f" at {event.time}" if event.time else ""
print(f" [{event.id}] {event.title}{time_str} ({event.category})")
if event.description:
print(f" {event.description}")
def get_date_input(prompt: str) -> str:
"""Get date input from user with validation"""
while True:
date_str = input(f"{prompt} (YYYY-MM-DD): ").strip()
if not date_str:
return ""
try:
datetime.strptime(date_str, "%Y-%m-%d")
return date_str
except ValueError:
print("Invalid date format. Please use YYYY-MM-DD.")
def get_time_input(prompt: str) -> str:
"""Get time input from user with validation"""
while True:
time_str = input(f"{prompt} (HH:MM, optional): ").strip()
if not time_str:
return ""
try:
datetime.strptime(time_str, "%H:%M")
return time_str
except ValueError:
print("Invalid time format. Please use HH:MM.")
def main():
"""Main function to run the calendar app"""
calendar_app = BasicCalendarApp()
while True:
print("\n=== Basic Calendar App ===")
print("1. Add Event")
print("2. View Today's Events")
print("3. View Upcoming Events")
print("4. View Monthly Calendar")
print("5. View Events by Date")
print("6. Search Events")
print("7. View Events by Category")
print("8. Edit Event")
print("9. Delete Event")
print("10. View Statistics")
print("11. Export Events")
print("12. Manage Categories")
print("0. Exit")
try:
choice = input("\nEnter your choice: ").strip()
if choice == '1':
print("\n=== Add New Event ===")
title = input("Event title: ").strip()
if not title:
print("Title cannot be empty!")
continue
date_str = get_date_input("Event date")
if not date_str:
print("Date is required!")
continue
time_str = get_time_input("Event time")
description = input("Description (optional): ").strip()
print("\nAvailable categories:")
categories = list(calendar_app.categories)
for i, cat in enumerate(categories, 1):
print(f" {i}. {cat}")
cat_choice = input("Choose category (number) or enter new: ").strip()
try:
cat_idx = int(cat_choice) - 1
if 0 <= cat_idx < len(categories):
category = categories[cat_idx]
else:
category = "General"
except ValueError:
category = cat_choice if cat_choice else "General"
event = calendar_app.add_event(title, date_str, time_str, description, category)
if event:
print(f"Event added successfully! ID: {event.id}")
else:
print("Failed to add event.")
elif choice == '2':
today_events = calendar_app.get_today_events()
display_events(today_events, "Today's Events")
elif choice == '3':
try:
days = int(input("Show events for next how many days? (default 7): ").strip() or "7")
upcoming = calendar_app.get_upcoming_events(days)
display_events(upcoming, f"Upcoming Events (Next {days} days)")
except ValueError:
print("Invalid number of days!")
elif choice == '4':
try:
year = int(input("Enter year (default current): ").strip() or str(date.today().year))
month = int(input("Enter month (1-12, default current): ").strip() or str(date.today().month))
if 1 <= month <= 12:
calendar_view = calendar_app.get_calendar_view(year, month)
print(calendar_view)
# Show events for the month
month_events = calendar_app.get_events_for_month(year, month)
if month_events:
display_events(month_events, f"Events in {calendar.month_name[month]} {year}")
else:
print("Invalid month! Please enter 1-12.")
except ValueError:
print("Invalid year or month!")
elif choice == '5':
date_str = get_date_input("Enter date to view")
if date_str:
date_events = calendar_app.get_events_for_date(date_str)
display_events(date_events, f"Events on {date_str}")
elif choice == '6':
query = input("Enter search query: ").strip()
if query:
results = calendar_app.search_events(query)
display_events(results, f"Search Results for '{query}'")
elif choice == '7':
print("\nAvailable categories:")
for category in sorted(calendar_app.categories):
print(f" • {category}")
category = input("\nEnter category name: ").strip()
if category:
category_events = calendar_app.get_events_by_category(category)
display_events(category_events, f"Events in '{category}' Category")
elif choice == '8':
event_id = input("Enter event ID to edit: ").strip()
event = calendar_app.get_event(event_id)
if event:
print(f"\nCurrent event: {event}")
print(f"Date: {event.date}, Time: {event.time or 'Not set'}")
print(f"Description: {event.description or 'None'}")
print(f"Category: {event.category}")
print("\nLeave blank to keep current value:")
new_title = input(f"New title ({event.title}): ").strip()
new_date = get_date_input(f"New date ({event.date})")
new_time = get_time_input(f"New time ({event.time or 'Not set'})")
new_desc = input(f"New description ({event.description or 'None'}): ").strip()
new_cat = input(f"New category ({event.category}): ").strip()
success = calendar_app.update_event(
event_id,
new_title if new_title else None,
new_date if new_date else None,
new_time if new_time else None,
new_desc if new_desc else None,
new_cat if new_cat else None
)
if success:
print("Event updated successfully!")
else:
print("Failed to update event.")
else:
print("Event not found!")
elif choice == '9':
event_id = input("Enter event ID to delete: ").strip()
event = calendar_app.get_event(event_id)
if event:
confirm = input(f"Are you sure you want to delete '{event.title}'? (y/N): ").strip().lower()
if confirm == 'y':
if calendar_app.delete_event(event_id):
print("Event deleted successfully!")
else:
print("Failed to delete event.")
else:
print("Deletion cancelled.")
else:
print("Event not found!")
elif choice == '10':
stats = calendar_app.get_event_statistics()
if stats:
print("\n=== Calendar Statistics ===")
print(f"Total events: {stats['total_events']}")
print(f"Today's events: {stats['today_events']}")
print(f"Upcoming events (30 days): {stats['upcoming_events']}")
print(f"Total categories: {stats['total_categories']}")
if stats['categories']:
print("\nEvents by category:")
for category, count in sorted(stats['categories'].items()):
print(f" {category}: {count}")
if stats['monthly_distribution']:
print("\nMonthly distribution:")
for month, count in sorted(stats['monthly_distribution'].items()):
print(f" {month}: {count}")
else:
print("No events to show statistics for.")
elif choice == '11':
filename = input("Enter filename for export (e.g., my_events.txt): ").strip()
if not filename:
filename = f"calendar_export_{date.today().strftime('%Y%m%d')}.txt"
start_date = get_date_input("Start date (optional, leave blank for all)")
end_date = get_date_input("End date (optional, leave blank for all)")
calendar_app.export_events(filename, start_date, end_date)
elif choice == '12':
print("\n=== Manage Categories ===")
print("Current categories:")
for category in sorted(calendar_app.categories):
print(f" • {category}")
new_category = input("\nAdd new category (or press Enter to skip): ").strip()
if new_category:
calendar_app.add_category(new_category)
print(f"Category '{new_category}' added!")
elif choice == '0':
print("Thank you for using Basic Calendar App!")
break
else:
print("Invalid choice. Please try again.")
except KeyboardInterrupt:
print("\n\nGoodbye!")
break
except Exception as e:
print(f"An error occurred: {e}")
if __name__ == "__main__":
main()
# Basic Calendar App
import calendar
import json
import os
from datetime import datetime, date, timedelta
from typing import Dict, List, Optional, Tuple
class Event:
def __init__(self, event_id: str, title: str, date: str, time: str = "",
description: str = "", category: str = "General"):
self.id = event_id
self.title = title
self.date = date # Format: YYYY-MM-DD
self.time = time # Format: HH:MM
self.description = description
self.category = category
self.created_at = datetime.now().isoformat()
def to_dict(self) -> Dict:
"""Convert event to dictionary"""
return {
'id': self.id,
'title': self.title,
'date': self.date,
'time': self.time,
'description': self.description,
'category': self.category,
'created_at': self.created_at
}
@classmethod
def from_dict(cls, data: Dict) -> 'Event':
"""Create event from dictionary"""
event = cls(
data['id'], data['title'], data['date'],
data.get('time', ''), data.get('description', ''),
data.get('category', 'General')
)
event.created_at = data.get('created_at', datetime.now().isoformat())
return event
def get_datetime(self) -> Optional[datetime]:
"""Get datetime object for the event"""
try:
if self.time:
datetime_str = f"{self.date} {self.time}"
return datetime.strptime(datetime_str, "%Y-%m-%d %H:%M")
else:
return datetime.strptime(self.date, "%Y-%m-%d")
except ValueError:
return None
def is_today(self) -> bool:
"""Check if event is today"""
return self.date == date.today().strftime("%Y-%m-%d")
def is_upcoming(self, days: int = 7) -> bool:
"""Check if event is within next N days"""
try:
event_date = datetime.strptime(self.date, "%Y-%m-%d").date()
today = date.today()
return today <= event_date <= today + timedelta(days=days)
except ValueError:
return False
def __str__(self):
time_str = f" at {self.time}" if self.time else ""
return f"{self.title}{time_str} ({self.category})"
class BasicCalendarApp:
def __init__(self, data_file: str = "calendar_data.json"):
self.data_file = data_file
self.events = {} # event_id -> Event
self.categories = set(["General", "Work", "Personal", "Health", "Education"])
self.load_data()
def load_data(self):
"""Load events from JSON file"""
if os.path.exists(self.data_file):
try:
with open(self.data_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# Load events
for event_data in data.get('events', []):
event = Event.from_dict(event_data)
self.events[event.id] = event
# Load categories
saved_categories = data.get('categories', [])
if saved_categories:
self.categories.update(saved_categories)
except (json.JSONDecodeError, KeyError) as e:
print(f"Error loading data: {e}")
self.events = {}
def save_data(self):
"""Save events to JSON file"""
try:
data = {
'events': [event.to_dict() for event in self.events.values()],
'categories': list(self.categories)
}
with open(self.data_file, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Error saving data: {e}")
def generate_event_id(self) -> str:
"""Generate unique event ID"""
if not self.events:
return "EVT001"
# Extract numeric part and increment
max_num = 0
for event_id in self.events.keys():
if event_id.startswith("EVT"):
try:
num = int(event_id[3:])
max_num = max(max_num, num)
except ValueError:
pass
return f"EVT{max_num + 1:03d}"
def add_event(self, title: str, date_str: str, time_str: str = "",
description: str = "", category: str = "General") -> Optional[Event]:
"""Add a new event"""
try:
# Validate date format
datetime.strptime(date_str, "%Y-%m-%d")
# Validate time format if provided
if time_str:
datetime.strptime(time_str, "%H:%M")
event_id = self.generate_event_id()
event = Event(event_id, title, date_str, time_str, description, category)
self.events[event_id] = event
# Add category if new
self.categories.add(category)
self.save_data()
return event
except ValueError as e:
print(f"Invalid date/time format: {e}")
return None
def get_event(self, event_id: str) -> Optional[Event]:
"""Get event by ID"""
return self.events.get(event_id)
def update_event(self, event_id: str, title: str = None, date_str: str = None,
time_str: str = None, description: str = None,
category: str = None) -> bool:
"""Update an existing event"""
event = self.get_event(event_id)
if not event:
return False
try:
if title is not None:
event.title = title
if date_str is not None:
datetime.strptime(date_str, "%Y-%m-%d") # Validate
event.date = date_str
if time_str is not None:
if time_str: # If not empty, validate
datetime.strptime(time_str, "%H:%M")
event.time = time_str
if description is not None:
event.description = description
if category is not None:
event.category = category
self.categories.add(category)
self.save_data()
return True
except ValueError as e:
print(f"Invalid date/time format: {e}")
return False
def delete_event(self, event_id: str) -> bool:
"""Delete an event"""
if event_id in self.events:
del self.events[event_id]
self.save_data()
return True
return False
def get_events_for_date(self, date_str: str) -> List[Event]:
"""Get all events for a specific date"""
return [event for event in self.events.values() if event.date == date_str]
def get_events_for_month(self, year: int, month: int) -> List[Event]:
"""Get all events for a specific month"""
month_events = []
for event in self.events.values():
try:
event_date = datetime.strptime(event.date, "%Y-%m-%d")
if event_date.year == year and event_date.month == month:
month_events.append(event)
except ValueError:
continue
# Sort by date and time
month_events.sort(key=lambda e: (e.date, e.time or "00:00"))
return month_events
def get_upcoming_events(self, days: int = 7) -> List[Event]:
"""Get upcoming events within N days"""
upcoming = [event for event in self.events.values() if event.is_upcoming(days)]
# Sort by date and time
upcoming.sort(key=lambda e: (e.date, e.time or "00:00"))
return upcoming
def get_today_events(self) -> List[Event]:
"""Get today's events"""
today_str = date.today().strftime("%Y-%m-%d")
today_events = self.get_events_for_date(today_str)
# Sort by time
today_events.sort(key=lambda e: e.time or "00:00")
return today_events
def get_events_by_category(self, category: str) -> List[Event]:
"""Get all events in a specific category"""
return [event for event in self.events.values() if event.category == category]
def search_events(self, query: str) -> List[Event]:
"""Search events by title or description"""
query = query.lower()
results = []
for event in self.events.values():
if (query in event.title.lower() or
query in event.description.lower() or
query in event.category.lower()):
results.append(event)
# Sort by date
results.sort(key=lambda e: e.date)
return results
def get_calendar_view(self, year: int, month: int) -> str:
"""Get calendar view for a month with events"""
cal = calendar.monthcalendar(year, month)
month_name = calendar.month_name[month]
# Get events for the month
month_events = self.get_events_for_month(year, month)
events_by_day = {}
for event in month_events:
try:
day = datetime.strptime(event.date, "%Y-%m-%d").day
if day not in events_by_day:
events_by_day[day] = []
events_by_day[day].append(event)
except ValueError:
continue
# Create calendar string
result = f"\n{month_name} {year}\n"
result += "Mo Tu We Th Fr Sa Su\n"
for week in cal:
week_str = ""
for day in week:
if day == 0:
week_str += " "
else:
day_str = f"{day:2d}"
# Mark days with events
if day in events_by_day:
day_str += "*"
else:
day_str += " "
week_str += day_str
result += week_str + "\n"
result += "\n* = Has events\n"
return result
def get_event_statistics(self) -> Dict:
"""Get event statistics"""
if not self.events:
return {}
total_events = len(self.events)
# Category distribution
category_count = {}
for event in self.events.values():
category_count[event.category] = category_count.get(event.category, 0) + 1
# Upcoming events
upcoming_count = len(self.get_upcoming_events(30))
today_count = len(self.get_today_events())
# Monthly distribution
monthly_count = {}
for event in self.events.values():
try:
event_date = datetime.strptime(event.date, "%Y-%m-%d")
month_key = f"{event_date.year}-{event_date.month:02d}"
monthly_count[month_key] = monthly_count.get(month_key, 0) + 1
except ValueError:
continue
return {
'total_events': total_events,
'today_events': today_count,
'upcoming_events': upcoming_count,
'categories': dict(category_count),
'monthly_distribution': dict(monthly_count),
'total_categories': len(self.categories)
}
def export_events(self, filename: str, start_date: str = None, end_date: str = None):
"""Export events to a text file"""
try:
events_to_export = list(self.events.values())
# Filter by date range if provided
if start_date:
events_to_export = [e for e in events_to_export if e.date >= start_date]
if end_date:
events_to_export = [e for e in events_to_export if e.date <= end_date]
# Sort by date
events_to_export.sort(key=lambda e: (e.date, e.time or "00:00"))
with open(filename, 'w', encoding='utf-8') as f:
f.write("Calendar Events Export\n")
f.write("=" * 50 + "\n\n")
current_date = None
for event in events_to_export:
if event.date != current_date:
current_date = event.date
f.write(f"\n{current_date} ({calendar.day_name[datetime.strptime(current_date, '%Y-%m-%d').weekday()]})\n")
f.write("-" * 30 + "\n")
time_str = f" at {event.time}" if event.time else ""
f.write(f"• {event.title}{time_str}\n")
if event.description:
f.write(f" Description: {event.description}\n")
f.write(f" Category: {event.category}\n\n")
print(f"Events exported to {filename}")
except Exception as e:
print(f"Error exporting events: {e}")
def add_category(self, category: str):
"""Add a new category"""
self.categories.add(category)
self.save_data()
def display_events(events: List[Event], title: str = "Events"):
"""Display a list of events"""
print(f"\n=== {title} ===")
if not events:
print("No events found.")
return
current_date = None
for event in events:
if event.date != current_date:
current_date = event.date
try:
date_obj = datetime.strptime(current_date, "%Y-%m-%d")
day_name = calendar.day_name[date_obj.weekday()]
print(f"\n{current_date} ({day_name})")
print("-" * 25)
except ValueError:
print(f"\n{current_date}")
print("-" * 25)
time_str = f" at {event.time}" if event.time else ""
print(f" [{event.id}] {event.title}{time_str} ({event.category})")
if event.description:
print(f" {event.description}")
def get_date_input(prompt: str) -> str:
"""Get date input from user with validation"""
while True:
date_str = input(f"{prompt} (YYYY-MM-DD): ").strip()
if not date_str:
return ""
try:
datetime.strptime(date_str, "%Y-%m-%d")
return date_str
except ValueError:
print("Invalid date format. Please use YYYY-MM-DD.")
def get_time_input(prompt: str) -> str:
"""Get time input from user with validation"""
while True:
time_str = input(f"{prompt} (HH:MM, optional): ").strip()
if not time_str:
return ""
try:
datetime.strptime(time_str, "%H:%M")
return time_str
except ValueError:
print("Invalid time format. Please use HH:MM.")
def main():
"""Main function to run the calendar app"""
calendar_app = BasicCalendarApp()
while True:
print("\n=== Basic Calendar App ===")
print("1. Add Event")
print("2. View Today's Events")
print("3. View Upcoming Events")
print("4. View Monthly Calendar")
print("5. View Events by Date")
print("6. Search Events")
print("7. View Events by Category")
print("8. Edit Event")
print("9. Delete Event")
print("10. View Statistics")
print("11. Export Events")
print("12. Manage Categories")
print("0. Exit")
try:
choice = input("\nEnter your choice: ").strip()
if choice == '1':
print("\n=== Add New Event ===")
title = input("Event title: ").strip()
if not title:
print("Title cannot be empty!")
continue
date_str = get_date_input("Event date")
if not date_str:
print("Date is required!")
continue
time_str = get_time_input("Event time")
description = input("Description (optional): ").strip()
print("\nAvailable categories:")
categories = list(calendar_app.categories)
for i, cat in enumerate(categories, 1):
print(f" {i}. {cat}")
cat_choice = input("Choose category (number) or enter new: ").strip()
try:
cat_idx = int(cat_choice) - 1
if 0 <= cat_idx < len(categories):
category = categories[cat_idx]
else:
category = "General"
except ValueError:
category = cat_choice if cat_choice else "General"
event = calendar_app.add_event(title, date_str, time_str, description, category)
if event:
print(f"Event added successfully! ID: {event.id}")
else:
print("Failed to add event.")
elif choice == '2':
today_events = calendar_app.get_today_events()
display_events(today_events, "Today's Events")
elif choice == '3':
try:
days = int(input("Show events for next how many days? (default 7): ").strip() or "7")
upcoming = calendar_app.get_upcoming_events(days)
display_events(upcoming, f"Upcoming Events (Next {days} days)")
except ValueError:
print("Invalid number of days!")
elif choice == '4':
try:
year = int(input("Enter year (default current): ").strip() or str(date.today().year))
month = int(input("Enter month (1-12, default current): ").strip() or str(date.today().month))
if 1 <= month <= 12:
calendar_view = calendar_app.get_calendar_view(year, month)
print(calendar_view)
# Show events for the month
month_events = calendar_app.get_events_for_month(year, month)
if month_events:
display_events(month_events, f"Events in {calendar.month_name[month]} {year}")
else:
print("Invalid month! Please enter 1-12.")
except ValueError:
print("Invalid year or month!")
elif choice == '5':
date_str = get_date_input("Enter date to view")
if date_str:
date_events = calendar_app.get_events_for_date(date_str)
display_events(date_events, f"Events on {date_str}")
elif choice == '6':
query = input("Enter search query: ").strip()
if query:
results = calendar_app.search_events(query)
display_events(results, f"Search Results for '{query}'")
elif choice == '7':
print("\nAvailable categories:")
for category in sorted(calendar_app.categories):
print(f" • {category}")
category = input("\nEnter category name: ").strip()
if category:
category_events = calendar_app.get_events_by_category(category)
display_events(category_events, f"Events in '{category}' Category")
elif choice == '8':
event_id = input("Enter event ID to edit: ").strip()
event = calendar_app.get_event(event_id)
if event:
print(f"\nCurrent event: {event}")
print(f"Date: {event.date}, Time: {event.time or 'Not set'}")
print(f"Description: {event.description or 'None'}")
print(f"Category: {event.category}")
print("\nLeave blank to keep current value:")
new_title = input(f"New title ({event.title}): ").strip()
new_date = get_date_input(f"New date ({event.date})")
new_time = get_time_input(f"New time ({event.time or 'Not set'})")
new_desc = input(f"New description ({event.description or 'None'}): ").strip()
new_cat = input(f"New category ({event.category}): ").strip()
success = calendar_app.update_event(
event_id,
new_title if new_title else None,
new_date if new_date else None,
new_time if new_time else None,
new_desc if new_desc else None,
new_cat if new_cat else None
)
if success:
print("Event updated successfully!")
else:
print("Failed to update event.")
else:
print("Event not found!")
elif choice == '9':
event_id = input("Enter event ID to delete: ").strip()
event = calendar_app.get_event(event_id)
if event:
confirm = input(f"Are you sure you want to delete '{event.title}'? (y/N): ").strip().lower()
if confirm == 'y':
if calendar_app.delete_event(event_id):
print("Event deleted successfully!")
else:
print("Failed to delete event.")
else:
print("Deletion cancelled.")
else:
print("Event not found!")
elif choice == '10':
stats = calendar_app.get_event_statistics()
if stats:
print("\n=== Calendar Statistics ===")
print(f"Total events: {stats['total_events']}")
print(f"Today's events: {stats['today_events']}")
print(f"Upcoming events (30 days): {stats['upcoming_events']}")
print(f"Total categories: {stats['total_categories']}")
if stats['categories']:
print("\nEvents by category:")
for category, count in sorted(stats['categories'].items()):
print(f" {category}: {count}")
if stats['monthly_distribution']:
print("\nMonthly distribution:")
for month, count in sorted(stats['monthly_distribution'].items()):
print(f" {month}: {count}")
else:
print("No events to show statistics for.")
elif choice == '11':
filename = input("Enter filename for export (e.g., my_events.txt): ").strip()
if not filename:
filename = f"calendar_export_{date.today().strftime('%Y%m%d')}.txt"
start_date = get_date_input("Start date (optional, leave blank for all)")
end_date = get_date_input("End date (optional, leave blank for all)")
calendar_app.export_events(filename, start_date, end_date)
elif choice == '12':
print("\n=== Manage Categories ===")
print("Current categories:")
for category in sorted(calendar_app.categories):
print(f" • {category}")
new_category = input("\nAdd new category (or press Enter to skip): ").strip()
if new_category:
calendar_app.add_category(new_category)
print(f"Category '{new_category}' added!")
elif choice == '0':
print("Thank you for using Basic Calendar App!")
break
else:
print("Invalid choice. Please try again.")
except KeyboardInterrupt:
print("\n\nGoodbye!")
break
except Exception as e:
print(f"An error occurred: {e}")
if __name__ == "__main__":
main()
Run it
python basiccalendarapp.pypython basiccalendarapp.py1. Add event
2. Today's events
3. Upcoming events
4. Monthly view
5. Events on date
6. Search
7. Edit / delete
8. Statistics
9. Export
10. Quit
> 4
January 2026
Mo Tu We Th Fr Sa Su
1 2 3 4
5 6 7 8* 9 10 11
12 13 14*15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
* = has events1. Add event
2. Today's events
3. Upcoming events
4. Monthly view
5. Events on date
6. Search
7. Edit / delete
8. Statistics
9. Export
10. Quit
> 4
January 2026
Mo Tu We Th Fr Sa Su
1 2 3 4
5 6 7 8* 9 10 11
12 13 14*15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
* = has eventsStep-by-Step Explanation
1. The EventEvent data class
from dataclasses import dataclass, field, asdict
from datetime import datetime, date, timedelta
@dataclass
class Event:
id: str
title: str
date: str # YYYY-MM-DD
time: str = "" # HH:MM
description: str = ""
category: str = "General"
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
def dt(self) -> datetime:
if self.time:
return datetime.strptime(f"{self.date} {self.time}", "%Y-%m-%d %H:%M")
return datetime.strptime(self.date, "%Y-%m-%d")
def is_upcoming(self, within_days: int = 7) -> bool:
return date.today() <= self.dt().date() <= date.today() + timedelta(days=within_days)
def to_dict(self): return asdict(self)
@classmethod
def from_dict(cls, d): return cls(**d)from dataclasses import dataclass, field, asdict
from datetime import datetime, date, timedelta
@dataclass
class Event:
id: str
title: str
date: str # YYYY-MM-DD
time: str = "" # HH:MM
description: str = ""
category: str = "General"
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
def dt(self) -> datetime:
if self.time:
return datetime.strptime(f"{self.date} {self.time}", "%Y-%m-%d %H:%M")
return datetime.strptime(self.date, "%Y-%m-%d")
def is_upcoming(self, within_days: int = 7) -> bool:
return date.today() <= self.dt().date() <= date.today() + timedelta(days=within_days)
def to_dict(self): return asdict(self)
@classmethod
def from_dict(cls, d): return cls(**d)Storing date and time as ISO strings is deliberate — JSON has no native datetime, and ISO format sorts correctly lexicographically.
2. The CalendarCalendar class
import json
from pathlib import Path
class Calendar:
def __init__(self, filename="calendar_data.json"):
self.path = Path(filename)
self.events: list[Event] = []
self.next_id = 1
self.load()
def load(self):
if self.path.exists() and self.path.stat().st_size:
data = json.loads(self.path.read_text(encoding="utf-8"))
self.events = [Event.from_dict(d) for d in data["events"]]
self.next_id = data.get("next_id", len(self.events) + 1)
def save(self):
self.path.write_text(json.dumps({
"events": [e.to_dict() for e in self.events],
"next_id": self.next_id,
}, indent=2), encoding="utf-8")
def add(self, title, date_str, time_str="", description="", category="General"):
eid = f"EVT{self.next_id:03d}"
self.events.append(Event(eid, title, date_str, time_str, description, category))
self.next_id += 1
self.save()
return eidimport json
from pathlib import Path
class Calendar:
def __init__(self, filename="calendar_data.json"):
self.path = Path(filename)
self.events: list[Event] = []
self.next_id = 1
self.load()
def load(self):
if self.path.exists() and self.path.stat().st_size:
data = json.loads(self.path.read_text(encoding="utf-8"))
self.events = [Event.from_dict(d) for d in data["events"]]
self.next_id = data.get("next_id", len(self.events) + 1)
def save(self):
self.path.write_text(json.dumps({
"events": [e.to_dict() for e in self.events],
"next_id": self.next_id,
}, indent=2), encoding="utf-8")
def add(self, title, date_str, time_str="", description="", category="General"):
eid = f"EVT{self.next_id:03d}"
self.events.append(Event(eid, title, date_str, time_str, description, category))
self.next_id += 1
self.save()
return eidEVT001EVT001, EVT002EVT002, ...... IDs are human-readable and survive sorting.
3. Date input with validation
def get_date(prompt: str) -> str:
while True:
raw = input(prompt + " (YYYY-MM-DD): ").strip()
try:
datetime.strptime(raw, "%Y-%m-%d")
return raw
except ValueError:
print("Bad format. Example: 2026-01-15")def get_date(prompt: str) -> str:
while True:
raw = input(prompt + " (YYYY-MM-DD): ").strip()
try:
datetime.strptime(raw, "%Y-%m-%d")
return raw
except ValueError:
print("Bad format. Example: 2026-01-15")strptimestrptime validates while it parses — fail-early.
4. Monthly view with event indicators
import calendar as cal
def month_view(self, year: int, month: int) -> str:
has_event = {e.date for e in self.events
if e.dt().year == year and e.dt().month == month}
text_cal = cal.month(year, month)
lines = text_cal.splitlines()
out = [lines[0], lines[1]]
for line in lines[2:]:
new = []
for piece in line.split():
d = f"{year:04d}-{month:02d}-{int(piece):02d}"
new.append(f"{piece}*" if d in has_event else piece.rjust(2))
out.append(" ".join(new))
return "\n".join(out)import calendar as cal
def month_view(self, year: int, month: int) -> str:
has_event = {e.date for e in self.events
if e.dt().year == year and e.dt().month == month}
text_cal = cal.month(year, month)
lines = text_cal.splitlines()
out = [lines[0], lines[1]]
for line in lines[2:]:
new = []
for piece in line.split():
d = f"{year:04d}-{month:02d}-{int(piece):02d}"
new.append(f"{piece}*" if d in has_event else piece.rjust(2))
out.append(" ".join(new))
return "\n".join(out)calendar.month()calendar.month() does the heavy formatting; we just sprinkle ** next to days with events.
5. Today / upcoming / search
def today(self):
today_str = date.today().isoformat()
return [e for e in self.events if e.date == today_str]
def upcoming(self, days=7):
return sorted([e for e in self.events if e.is_upcoming(days)],
key=lambda e: e.dt())
def search(self, query: str):
q = query.lower()
return [e for e in self.events
if q in e.title.lower() or q in e.description.lower()]def today(self):
today_str = date.today().isoformat()
return [e for e in self.events if e.date == today_str]
def upcoming(self, days=7):
return sorted([e for e in self.events if e.is_upcoming(days)],
key=lambda e: e.dt())
def search(self, query: str):
q = query.lower()
return [e for e in self.events
if q in e.title.lower() or q in e.description.lower()]Recurring Events
A real calendar must handle “every Monday at 10:00” or “the 15th of every month.” Store recurrence as an extra field:
@dataclass
class Event:
# … existing fields …
recurrence: str = "" # "daily", "weekly", "monthly", "yearly", ""
def expand(event: Event, until: date) -> list[Event]:
out = [event]
start = event.dt().date()
if event.recurrence == "daily":
step = timedelta(days=1)
elif event.recurrence == "weekly":
step = timedelta(weeks=1)
elif event.recurrence == "monthly":
# naive month-step — use python-dateutil for correctness
step = timedelta(days=30)
else:
return out
d = start + step
while d <= until:
out.append(replace(event, date=d.isoformat()))
d += step
return out@dataclass
class Event:
# … existing fields …
recurrence: str = "" # "daily", "weekly", "monthly", "yearly", ""
def expand(event: Event, until: date) -> list[Event]:
out = [event]
start = event.dt().date()
if event.recurrence == "daily":
step = timedelta(days=1)
elif event.recurrence == "weekly":
step = timedelta(weeks=1)
elif event.recurrence == "monthly":
# naive month-step — use python-dateutil for correctness
step = timedelta(days=30)
else:
return out
d = start + step
while d <= until:
out.append(replace(event, date=d.isoformat()))
d += step
return outFor production correctness, use the dateutil.rruledateutil.rrule library (RFC 5545 compliant) — it handles “second Tuesday of the month” and exceptions cleanly.
ICS Export (Google / Apple Calendar Compatible)
def to_ics(events: list[Event]) -> str:
lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//PCH//Calendar//EN"]
for e in events:
dt = e.dt()
end = dt + timedelta(hours=1)
lines += [
"BEGIN:VEVENT",
f"UID:{e.id}@pythoncentralhub",
f"DTSTAMP:{datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')}",
f"DTSTART:{dt.strftime('%Y%m%dT%H%M%S')}",
f"DTEND:{end.strftime('%Y%m%dT%H%M%S')}",
f"SUMMARY:{e.title}",
f"DESCRIPTION:{e.description}",
f"CATEGORIES:{e.category}",
"END:VEVENT",
]
lines.append("END:VCALENDAR")
return "\r\n".join(lines)def to_ics(events: list[Event]) -> str:
lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//PCH//Calendar//EN"]
for e in events:
dt = e.dt()
end = dt + timedelta(hours=1)
lines += [
"BEGIN:VEVENT",
f"UID:{e.id}@pythoncentralhub",
f"DTSTAMP:{datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')}",
f"DTSTART:{dt.strftime('%Y%m%dT%H%M%S')}",
f"DTEND:{end.strftime('%Y%m%dT%H%M%S')}",
f"SUMMARY:{e.title}",
f"DESCRIPTION:{e.description}",
f"CATEGORIES:{e.category}",
"END:VEVENT",
]
lines.append("END:VCALENDAR")
return "\r\n".join(lines)Save as calendar.icscalendar.ics. Drop into Google Calendar’s import dialog or Apple Calendar — every event appears. pip install icalendarpip install icalendar for a production-grade library.
Desktop Reminders
Combine with Simple Reminder App. On startup, schedule one timer per upcoming event:
import threading
from plyer import notification
def schedule(event):
delay = (event.dt() - datetime.now()).total_seconds()
if delay <= 0: return
def fire():
notification.notify(title=event.title,
message=event.description or "Event now.",
timeout=10)
threading.Timer(delay, fire).start()import threading
from plyer import notification
def schedule(event):
delay = (event.dt() - datetime.now()).total_seconds()
if delay <= 0: return
def fire():
notification.notify(title=event.title,
message=event.description or "Event now.",
timeout=10)
threading.Timer(delay, fire).start()Common Mistakes
| Problem | Cause | Fix |
|---|---|---|
ValueError: time data '...' does not match formatValueError: time data '...' does not match format | Mixed date formats | Validate with strptimestrptime on entry |
| Events on Feb 30 accepted | No real-date validation | strptimestrptime rejects them — use it |
| Timezone confusion | Naive datetimes | Use datetime.now(timezone.utc)datetime.now(timezone.utc) consistently |
| Recurring events past year 9999 | No upper bound | Cap with a RRULE UNTILRRULE UNTIL or hard limit |
| Two events same minute reorder randomly | Sort key is date only | Sort by full dt()dt() |
next_idnext_id collisions on restart | Did not save | Persist counter alongside events |
Variations to Try
1. Tkinter GUI
A real month grid built from ttk.Treeviewttk.Treeview (or hand-drawn with a CanvasCanvas). Click a day to see events; double-click to add.
2. iCal sync
Periodically download a friend’s .ics.ics URL and merge in their events.
3. Google Calendar API
Authenticate with OAuth and push events into the user’s real Google Calendar.
4. Conflict detection
Refuse (or warn) when adding an event that overlaps an existing one.
5. Reminders with snooze
Snoozeable desktop notifications. See Simple Reminder App.
6. Timezones
Store events as UTC; display in user’s local timezone via zoneinfozoneinfo (built into 3.9+).
7. Web frontend
Flask app + a calendar widget. See Basic Web Server.
8. Multi-user with auth
SQLite + user accounts. Each user sees only their events.
9. Statistics dashboard
Plot events per category, per month, per weekday with matplotlibmatplotlib.
10. Natural-language input
pip install dateparserpip install dateparser so “tomorrow at 3pm — dentist” parses cleanly.
11. Color categories
Each category gets a color. ANSI in terminal, hex in GUI/web.
12. iOS / Android sync
Generate ICS, host on a small Flask server, subscribe to its URL from the phone’s calendar app.
Why Real Calendars Are Hard
Once you go past toy versions:
- Daylight saving — events move an hour, sometimes split.
- Cross-timezone scheduling — “9 AM PT” means three different wall times across the year.
- Rule exceptions — “every Monday except holidays.”
- Mutable past events — moving an instance of a recurring event without breaking others.
- Conflict resolution for events synced from multiple sources.
- Notifications that must fire even when the device is asleep.
Every major calendar app (Google, Apple, Outlook) has hundreds of engineers working on these — but the foundation is exactly the data model you just built.
Real-World Applications
- Personal scheduling — your own habit tracker.
- Internal team calendars for small companies.
- Booking systems for one-person businesses (consultants, tutors).
- Project planning — milestones, sprints, releases.
- Event-driven automation — schedule scripts to run at specific times.
Educational Value
datetimedatetimemastery — parsing, formatting, arithmetic.calendarcalendar— the standard library’s underused gem.- Dataclass + JSON persistence — reusable for any small CRUD app.
- Recurrence rules (RRULE) — the same model every calendar uses.
- Standards (iCalendar / RFC 5545) — interoperability with real-world tools.
Next Steps
- Add recurring events with
dateutil.rruledateutil.rrule. - Implement ICS export above and try it in Google Calendar.
- Combine with Simple Reminder App for desktop reminders.
- Wrap with a Tkinter GUI that shows a real grid.
- Move to SQLite for thousands of events.
- Layer OAuth + Google Calendar API for two-way sync.
Conclusion
You built a complete calendar with events, categories, search, monthly view, statistics, and export. The data model is the same one every commercial calendar uses; the difference between this and Google Calendar is mostly scale and integration. Full source on GitHub. Find more productivity projects on Python Central Hub.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
