Add calendar invites panel to Calendar TUI sidebar

- Create InvitesPanel widget showing pending invites from Microsoft Graph
- Add fetch_pending_invites() and respond_to_invite() API functions
- Invites load asynchronously in background on app mount
- Display invite subject, date/time, and organizer
- Add 'i' keybinding to focus invites panel
- Style: tentative invites shown in warning color
This commit is contained in:
Bendt
2025-12-19 10:51:15 -05:00
parent a82f001918
commit 3c45e2a154
4 changed files with 379 additions and 1 deletions

View File

@@ -19,6 +19,7 @@ from textual.reactive import reactive
from src.calendar.backend import CalendarBackend, Event
from src.calendar.widgets.WeekGrid import WeekGrid
from src.calendar.widgets.MonthCalendar import MonthCalendar
from src.calendar.widgets.InvitesPanel import InvitesPanel, CalendarInvite
from src.calendar.widgets.AddEventForm import EventFormData
from src.utils.shared_config import get_theme_name
@@ -72,6 +73,13 @@ class CalendarApp(App):
#sidebar-calendar {
height: auto;
}
#sidebar-invites {
height: auto;
margin-top: 1;
border-top: solid $surface-darken-1;
padding-top: 1;
}
#week-grid {
height: 1fr;
@@ -120,6 +128,7 @@ class CalendarApp(App):
Binding("g", "goto_today", "Today", show=True),
Binding("w", "toggle_weekends", "Weekends", show=True),
Binding("s", "toggle_sidebar", "Sidebar", show=True),
Binding("i", "focus_invites", "Invites", show=True),
Binding("r", "refresh", "Refresh", show=True),
Binding("enter", "view_event", "View", show=True),
Binding("a", "add_event", "Add", show=True),
@@ -132,9 +141,11 @@ class CalendarApp(App):
# Instance attributes
backend: Optional[CalendarBackend]
_invites: list[CalendarInvite]
def __init__(self, backend: Optional[CalendarBackend] = None):
super().__init__()
self._invites = []
if backend:
self.backend = backend
@@ -150,6 +161,7 @@ class CalendarApp(App):
with Horizontal(id="main-content"):
with Vertical(id="sidebar"):
yield MonthCalendar(id="sidebar-calendar")
yield InvitesPanel(id="sidebar-invites")
yield WeekGrid(id="week-grid")
yield Static(id="event-detail", classes="hidden")
yield CalendarStatusBar(id="status-bar")
@@ -165,10 +177,75 @@ class CalendarApp(App):
# Sync sidebar calendar with current week
self._sync_sidebar_calendar()
# Load invites in background
self.run_worker(self._load_invites_async(), exclusive=True)
# Update status bar and title
self._update_status()
self._update_title()
async def _load_invites_async(self) -> None:
"""Load pending calendar invites from Microsoft Graph."""
try:
from src.services.microsoft_graph.auth import get_access_token
from src.services.microsoft_graph.calendar import fetch_pending_invites
from dateutil import parser as date_parser
# Get auth token
scopes = ["https://graph.microsoft.com/Calendars.Read"]
_, headers = get_access_token(scopes)
# Fetch invites
raw_invites = await fetch_pending_invites(headers, days_forward=30)
# Convert to CalendarInvite objects
invites = []
for inv in raw_invites:
try:
start_str = inv.get("start", {}).get("dateTime", "")
end_str = inv.get("end", {}).get("dateTime", "")
start_dt = (
date_parser.parse(start_str) if start_str else datetime.now()
)
end_dt = date_parser.parse(end_str) if end_str else start_dt
organizer_data = inv.get("organizer", {}).get("emailAddress", {})
organizer_name = organizer_data.get(
"name", organizer_data.get("address", "Unknown")
)
invite = CalendarInvite(
id=inv.get("id", ""),
subject=inv.get("subject", "No Subject"),
organizer=organizer_name,
start=start_dt,
end=end_dt,
location=inv.get("location", {}).get("displayName"),
is_all_day=inv.get("isAllDay", False),
response_status=inv.get("responseStatus", {}).get(
"response", "notResponded"
),
)
invites.append(invite)
except Exception as e:
logger.warning(f"Failed to parse invite: {e}")
# Update the panel
self._invites = invites
self.call_from_thread(self._update_invites_panel)
except Exception as e:
logger.warning(f"Failed to load invites: {e}")
# Silently fail - invites are optional
def _update_invites_panel(self) -> None:
"""Update the invites panel with loaded invites."""
try:
panel = self.query_one("#sidebar-invites", InvitesPanel)
panel.set_invites(self._invites)
except Exception:
pass
def _sync_sidebar_calendar(self) -> None:
"""Sync the sidebar calendar with the main week grid."""
try:
@@ -443,6 +520,7 @@ Keybindings:
g - Go to today
w - Toggle weekends (5/7 days)
s - Toggle sidebar
i - Focus invites panel
Enter - View event details
a - Add new event
r - Refresh
@@ -450,6 +528,16 @@ Keybindings:
"""
self.notify(help_text.strip(), timeout=10)
def action_focus_invites(self) -> None:
"""Focus on the invites panel and show invite count."""
if not self.show_sidebar:
self.action_toggle_sidebar()
if self._invites:
self.notify(f"You have {len(self._invites)} pending invite(s)")
else:
self.notify("No pending invites")
def run_app(backend: Optional[CalendarBackend] = None) -> None:
"""Run the Calendar TUI application."""

View File

@@ -0,0 +1,209 @@
"""Calendar invites panel widget for Calendar TUI sidebar.
Displays pending calendar invites that need a response.
"""
from dataclasses import dataclass
from datetime import datetime
from typing import List, Optional
from rich.segment import Segment
from rich.style import Style
from textual.message import Message
from textual.reactive import reactive
from textual.strip import Strip
from textual.widget import Widget
@dataclass
class CalendarInvite:
"""A calendar invite pending response."""
id: str
subject: str
organizer: str
start: datetime
end: datetime
location: Optional[str] = None
is_all_day: bool = False
response_status: str = "notResponded" # notResponded, tentativelyAccepted
class InvitesPanel(Widget):
"""Panel showing pending calendar invites."""
DEFAULT_CSS = """
InvitesPanel {
width: 100%;
height: auto;
min-height: 3;
padding: 0 1;
}
"""
# Reactive attributes
invites: reactive[List[CalendarInvite]] = reactive(list)
selected_index: reactive[int] = reactive(0)
class InviteSelected(Message):
"""An invite was selected."""
def __init__(self, invite: CalendarInvite) -> None:
super().__init__()
self.invite = invite
class InviteRespond(Message):
"""User wants to respond to an invite."""
def __init__(self, invite: CalendarInvite, response: str) -> None:
super().__init__()
self.invite = invite
self.response = response # accept, tentativelyAccept, decline
def __init__(
self,
invites: Optional[List[CalendarInvite]] = None,
name: Optional[str] = None,
id: Optional[str] = None,
classes: Optional[str] = None,
) -> None:
super().__init__(name=name, id=id, classes=classes)
if invites:
self.invites = invites
def _get_theme_color(self, color_name: str) -> str:
"""Get a color from the current theme."""
try:
theme = self.app.current_theme
color = getattr(theme, color_name, None)
if color:
return str(color)
except Exception:
pass
# Fallback colors
fallbacks = {
"secondary": "#81A1C1",
"primary": "#88C0D0",
"accent": "#B48EAD",
"foreground": "#D8DEE9",
"surface": "#3B4252",
"warning": "#EBCB8B",
}
return fallbacks.get(color_name, "white")
def get_content_height(self, container, viewport, width: int) -> int:
"""Calculate height: header + invite rows."""
# Header (1) + invites (2 lines each) + empty message if no invites
if not self.invites:
return 2 # Header + "No pending invites"
return 1 + len(self.invites) * 2 # Header + 2 lines per invite
def render_line(self, y: int) -> Strip:
"""Render a line of the panel."""
if y == 0:
return self._render_header()
if not self.invites:
if y == 1:
return self._render_empty_message()
return Strip.blank(self.size.width)
# Each invite takes 2 lines
invite_idx = (y - 1) // 2
line_in_invite = (y - 1) % 2
if 0 <= invite_idx < len(self.invites):
return self._render_invite_line(
self.invites[invite_idx],
line_in_invite,
invite_idx == self.selected_index,
)
return Strip.blank(self.size.width)
def _render_header(self) -> Strip:
"""Render the panel header."""
primary_color = self._get_theme_color("primary")
count = len(self.invites)
header = f"Invites ({count})" if count else "Invites"
header = header[: self.size.width].ljust(self.size.width)
style = Style(bold=True, color=primary_color)
return Strip([Segment(header, style)])
def _render_empty_message(self) -> Strip:
"""Render empty state message."""
msg = "No pending invites"
msg = msg[: self.size.width].ljust(self.size.width)
style = Style(color="bright_black")
return Strip([Segment(msg, style)])
def _render_invite_line(
self, invite: CalendarInvite, line: int, is_selected: bool
) -> Strip:
"""Render a line of an invite."""
if line == 0:
# First line: subject (truncated)
text = invite.subject[: self.size.width - 2]
if is_selected:
text = "> " + text[: self.size.width - 2]
text = text[: self.size.width].ljust(self.size.width)
if is_selected:
style = Style(bold=True, reverse=True)
else:
style = Style()
return Strip([Segment(text, style)])
else:
# Second line: date/time and organizer
date_str = invite.start.strftime("%m/%d %H:%M")
organizer = invite.organizer[:15] if invite.organizer else ""
info = f" {date_str} - {organizer}"
info = info[: self.size.width].ljust(self.size.width)
warning_color = self._get_theme_color("warning")
if invite.response_status == "tentativelyAccepted":
style = Style(color=warning_color, italic=True)
else:
style = Style(color="bright_black")
return Strip([Segment(info, style)])
def set_invites(self, invites: List[CalendarInvite]) -> None:
"""Update the list of invites."""
self.invites = invites
if self.selected_index >= len(invites):
self.selected_index = max(0, len(invites) - 1)
self.refresh()
def select_next(self) -> None:
"""Select the next invite."""
if self.invites and self.selected_index < len(self.invites) - 1:
self.selected_index += 1
self.refresh()
def select_previous(self) -> None:
"""Select the previous invite."""
if self.invites and self.selected_index > 0:
self.selected_index -= 1
self.refresh()
def get_selected_invite(self) -> Optional[CalendarInvite]:
"""Get the currently selected invite."""
if self.invites and 0 <= self.selected_index < len(self.invites):
return self.invites[self.selected_index]
return None
def on_click(self, event) -> None:
"""Handle mouse clicks."""
y = event.y
if y == 0 or not self.invites:
return
# Calculate which invite was clicked
invite_idx = (y - 1) // 2
if 0 <= invite_idx < len(self.invites):
self.selected_index = invite_idx
self.post_message(self.InviteSelected(self.invites[invite_idx]))
self.refresh()

View File

@@ -3,5 +3,13 @@
from .WeekGrid import WeekGrid
from .AddEventForm import AddEventForm, EventFormData
from .MonthCalendar import MonthCalendar
from .InvitesPanel import InvitesPanel, CalendarInvite
__all__ = ["WeekGrid", "AddEventForm", "EventFormData", "MonthCalendar"]
__all__ = [
"WeekGrid",
"AddEventForm",
"EventFormData",
"MonthCalendar",
"InvitesPanel",
"CalendarInvite",
]

View File

@@ -468,3 +468,76 @@ async def sync_local_calendar_changes(
)
return created_count, deleted_count
async def fetch_pending_invites(headers, days_forward=30):
"""
Fetch calendar invites that need a response (pending/tentative).
Args:
headers (dict): Headers including authentication.
days_forward (int): Number of days to look forward.
Returns:
list: List of invite dictionaries with response status info.
"""
start_date = datetime.now()
end_date = start_date + timedelta(days=days_forward)
start_date_str = start_date.strftime("%Y-%m-%dT00:00:00Z")
end_date_str = end_date.strftime("%Y-%m-%dT23:59:59Z")
# Fetch events with response status
calendar_url = (
f"https://graph.microsoft.com/v1.0/me/calendarView?"
f"startDateTime={start_date_str}&endDateTime={end_date_str}&"
f"$select=id,subject,organizer,start,end,location,isAllDay,responseStatus,isCancelled&"
f"$filter=responseStatus/response eq 'notResponded' or responseStatus/response eq 'tentativelyAccepted'&"
f"$orderby=start/dateTime"
)
invites = []
try:
response_data = await fetch_with_aiohttp(calendar_url, headers)
invites.extend(response_data.get("value", []))
# Handle pagination
next_link = response_data.get("@odata.nextLink")
while next_link:
response_data = await fetch_with_aiohttp(next_link, headers)
invites.extend(response_data.get("value", []))
next_link = response_data.get("@odata.nextLink")
except Exception as e:
print(f"Error fetching pending invites: {e}")
return invites
async def respond_to_invite(headers, event_id, response):
"""
Respond to a calendar invite.
Args:
headers (dict): Authentication headers
event_id (str): The ID of the event to respond to
response (str): Response type - 'accept', 'tentativelyAccept', or 'decline'
Returns:
bool: True if response was successful
"""
valid_responses = ["accept", "tentativelyAccept", "decline"]
if response not in valid_responses:
print(f"Invalid response type: {response}. Must be one of {valid_responses}")
return False
try:
response_url = (
f"https://graph.microsoft.com/v1.0/me/events/{event_id}/{response}"
)
status = await post_with_aiohttp(response_url, headers, {})
return status in (200, 202)
except Exception as e:
print(f"Error responding to invite: {e}")
return False