diff --git a/src/calendar/app.py b/src/calendar/app.py index efa99b8..d825e08 100644 --- a/src/calendar/app.py +++ b/src/calendar/app.py @@ -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.""" diff --git a/src/calendar/widgets/InvitesPanel.py b/src/calendar/widgets/InvitesPanel.py new file mode 100644 index 0000000..d9844b0 --- /dev/null +++ b/src/calendar/widgets/InvitesPanel.py @@ -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() diff --git a/src/calendar/widgets/__init__.py b/src/calendar/widgets/__init__.py index c45cf59..58fcb01 100644 --- a/src/calendar/widgets/__init__.py +++ b/src/calendar/widgets/__init__.py @@ -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", +] diff --git a/src/services/microsoft_graph/calendar.py b/src/services/microsoft_graph/calendar.py index 1ff481b..4e28904 100644 --- a/src/services/microsoft_graph/calendar.py +++ b/src/services/microsoft_graph/calendar.py @@ -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