diff --git a/src/app.rs b/src/app.rs index 7d1232d..73ab7ac 100644 --- a/src/app.rs +++ b/src/app.rs @@ -23,6 +23,7 @@ pub struct SyncStats { pub struct App { pub lists: Vec, pub tasks: Vec, + pub calendar_events: Vec, pub selected_list: usize, pub selected_task: usize, pub focus: Focus, @@ -38,6 +39,7 @@ pub struct App { pub task_list_scroll: u16, pub detail_scroll: u16, pub notes_scroll: u16, + pub calendar_scroll: u16, pub db: Arc, #[allow(dead_code)] pub api_client: Arc, @@ -66,7 +68,7 @@ pub enum SyncCommand { } impl App { - pub fn new(db: Arc, api_client: Arc, sync_tx: mpsc::Sender) -> Self { + pub fn new(db: Arc, api_client: Arc, sync_tx: mpsc::Sender, _calendar_events_shared: Arc>>) -> Self { let has_token = api_client.has_token(); let (auth_tx, auth_rx) = std_mpsc::channel(); @@ -91,6 +93,7 @@ impl App { Self { lists, tasks, + calendar_events: Vec::new(), selected_list: 0, selected_task: 0, focus: Focus::Tabs, @@ -106,6 +109,7 @@ impl App { task_list_scroll: 0, detail_scroll: 0, notes_scroll: 0, + calendar_scroll: 0, db, api_client, needs_auth: !has_token, @@ -282,7 +286,8 @@ impl App { self.focus = match self.focus { Focus::Tabs => Focus::TaskList, Focus::TaskList => Focus::Detail, - Focus::Detail => Focus::Tabs, + Focus::Detail => Focus::Calendar, + Focus::Calendar => Focus::Tabs, }; } KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => { @@ -305,6 +310,9 @@ impl App { Focus::Detail => { self.detail_scroll = self.detail_scroll.saturating_sub(1); } + Focus::Calendar => { + self.calendar_scroll = self.calendar_scroll.saturating_sub(1); + } _ => {} }, KeyCode::Down => match self.focus { @@ -317,6 +325,9 @@ impl App { Focus::Detail => { self.detail_scroll += 1; } + Focus::Calendar => { + self.calendar_scroll += 1; + } _ => {} }, KeyCode::Right => { @@ -729,7 +740,7 @@ impl App { self.load_tasks(); } } - Focus::TaskList | Focus::Detail => { + Focus::TaskList | Focus::Detail | Focus::Calendar => { if !self.tasks.is_empty() && self.selected_task < self.tasks.len() { let task = &self.tasks[self.selected_task]; let task_id = task.id.clone(); diff --git a/src/domain/models.rs b/src/domain/models.rs index 752f9bd..b437872 100644 --- a/src/domain/models.rs +++ b/src/domain/models.rs @@ -46,3 +46,11 @@ pub struct SyncQueueItem { } pub const MAX_SYNC_RETRIES: i32 = 3; + +#[derive(Debug, Clone)] +pub struct CalendarEvent { + pub summary: String, + pub start: Option, + pub end: Option, + pub location: Option, +} diff --git a/src/infrastructure/api.rs b/src/infrastructure/api.rs index 5134731..16fe926 100644 --- a/src/infrastructure/api.rs +++ b/src/infrastructure/api.rs @@ -33,7 +33,10 @@ pub struct ApiClient { token_path: PathBuf, } -const SCOPES: &[&str] = &["https://www.googleapis.com/auth/tasks"]; +const SCOPES: &[&str] = &[ + "https://www.googleapis.com/auth/tasks", + "https://www.googleapis.com/auth/calendar.readonly", +]; impl ApiClient { pub async fn new(secret_path: impl AsRef) -> Result { @@ -455,4 +458,60 @@ impl ApiClient { Ok(()) } + + pub async fn fetch_upcoming_events(&self, max_results: u32) -> Result, ApiError> { + let token = self.get_token().await?; + + let now = chrono::Utc::now(); + let week_later = now + chrono::Duration::days(7); + + let resp = self + .client + .get("https://www.googleapis.com/calendar/v3/calendars/primary/events") + .bearer_auth(&token) + .query(&[ + ("orderBy", "startTime"), + ("singleEvents", "true"), + ("timeMin", &now.format("%Y-%m-%dT%H:%M:%SZ").to_string()), + ("timeMax", &week_later.format("%Y-%m-%dT%H:%M:%SZ").to_string()), + ("maxResults", &max_results.to_string()), + ]) + .send() + .await + .map_err(|e| ApiError::Network(e.to_string()))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(ApiError::Api(format!("Calendar fetch failed: {} - {}", status, body))); + } + + let body: serde_json::Value = resp.json().await + .map_err(|e| ApiError::Api(format!("Calendar JSON parse error: {}", e)))?; + + let items = body["items"].as_array() + .map(|arr| { + arr.iter().filter_map(|item| { + let summary = item["summary"].as_str()?.to_string(); + let start = parse_calendar_time(&item["start"]); + let end = parse_calendar_time(&item["end"]); + let location = item["location"].as_str().map(|s| s.to_string()); + Some(CalendarEvent { summary, start, end, location }) + }).collect::>() + }) + .unwrap_or_default(); + + Ok(items) + } +} + +fn parse_calendar_time(obj: &serde_json::Value) -> Option { + if let Some(dt) = obj["dateTime"].as_str() { + chrono::DateTime::parse_from_rfc3339(dt).ok().map(|d| d.naive_local()) + } else if let Some(d) = obj["date"].as_str() { + chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok() + .map(|date| date.and_hms_opt(0, 0, 0).unwrap()) + } else { + None + } } diff --git a/src/main.rs b/src/main.rs index d28e6d9..7ed82b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,19 +83,21 @@ fn main() -> io::Result<()> { let network_status = Arc::new(Mutex::new(NetworkStatus::Online)); let sync_stats = Arc::new(Mutex::new(SyncStats::default())); + let calendar_events_shared = Arc::new(Mutex::new(Vec::::new())); let (sync_tx, mut sync_rx) = tokio::sync::mpsc::channel::(32); - let mut app = App::new(db.clone(), api_client.clone(), sync_tx.clone()); + let mut app = App::new(db.clone(), api_client.clone(), sync_tx.clone(), calendar_events_shared.clone()); let network_clone = network_status.clone(); let stats_clone = sync_stats.clone(); let db_clone = db.clone(); let api_clone = api_client.clone(); + let cal_clone = calendar_events_shared.clone(); std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async move { - run_sync_engine(db_clone, api_clone, network_clone, stats_clone, &mut sync_rx).await; + run_sync_engine(db_clone, api_clone, network_clone, stats_clone, cal_clone, &mut sync_rx).await; }); }); @@ -123,12 +125,18 @@ fn main() -> io::Result<()> { app.sync_stats = guard.clone(); } + { + let guard = calendar_events_shared.blocking_lock(); + app.calendar_events = guard.clone(); + } + // Reload lists/tasks if sync engine changed data in background app.refresh_if_needed(); let view = AppView { lists: &app.lists, tasks: &app.tasks, + calendar_events: &app.calendar_events, selected_list: app.selected_list, selected_task: app.selected_task, focus: app.focus.clone(), @@ -142,6 +150,7 @@ fn main() -> io::Result<()> { task_list_scroll: app.task_list_scroll, detail_scroll: app.detail_scroll, notes_scroll: app.notes_scroll, + calendar_scroll: app.calendar_scroll, auth_error: app.auth_error.as_deref(), sync_stats: &app.sync_stats, }; @@ -165,6 +174,7 @@ async fn run_sync_engine( api: Arc, network_status: Arc>, sync_stats: Arc>, + calendar_events: Arc>>, rx: &mut tokio::sync::mpsc::Receiver, ) { loop { @@ -175,9 +185,11 @@ async fn run_sync_engine( Some(SyncCommand::FullSync) => { push_sync(&db, &api, &network_status, &sync_stats).await; pull_sync(&db, &api, &network_status, &sync_stats, true).await; + refresh_calendar(&api, &calendar_events, &network_status).await; } Some(SyncCommand::InitialSync) => { run_initial_sync(&db, &api, &network_status, &sync_stats).await; + refresh_calendar(&api, &calendar_events, &network_status).await; } Some(SyncCommand::Shutdown) | None => break, } @@ -386,3 +398,20 @@ async fn pull_sync( stats.tasks_changed = total_tasks; stats.version += 1; } + +async fn refresh_calendar( + api: &Arc, + events_shared: &Arc>>, + network_status: &Arc>, +) { + match api.fetch_upcoming_events(15).await { + Ok(events) => { + let mut guard = events_shared.lock().await; + *guard = events; + } + Err(e) => { + eprintln!("[task_app] Calendar fetch failed: {}", e); + *network_status.lock().await = NetworkStatus::Offline; + } + } +} diff --git a/src/ui/components.rs b/src/ui/components.rs index 925efac..48cad86 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -595,6 +595,77 @@ pub fn render_device_auth_popup( frame.render_widget(paragraph, popup_area); } +pub fn render_calendar_panel( + frame: &mut Frame, + area: Rect, + events: &[CalendarEvent], + focused: bool, + scroll: u16, +) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray })) + .title(" Calendar ") + .title_alignment(Alignment::Left); + + if events.is_empty() { + let paragraph = Paragraph::new(Text::from(Line::from(Span::styled( + " No upcoming events ", + Style::default().fg(Color::DarkGray), + )))) + .block(block) + .alignment(Alignment::Center); + frame.render_widget(paragraph, area); + return; + } + + let mut lines: Vec = Vec::new(); + let mut current_date: Option = None; + + for event in events { + if let Some(start) = event.start { + let event_date = start.date(); + if Some(event_date) != current_date { + current_date = Some(event_date); + let day_header = format!( + " --- {} {} --- ", + event_date.format("%A"), + event_date.format("%d/%m"), + ); + lines.push(Line::from(Span::styled( + day_header, + Style::default().fg(Color::Gray), + ))); + } + + let time_str = start.format("%H:%M").to_string(); + let summary = &event.summary; + let line_text = if summary.len() > 30 { + format!(" {} {:.30}…", time_str, summary) + } else { + format!(" {} {}", time_str, summary) + }; + lines.push(Line::from(Span::styled( + line_text, + Style::default().fg(DETAIL_COLOR), + ))); + } + } + + let inner_h = (area.height as usize).saturating_sub(2); + let visible_lines: Vec = lines + .iter() + .skip(scroll as usize) + .take(inner_h) + .cloned() + .collect(); + + let paragraph = Paragraph::new(Text::from(visible_lines)) + .block(block) + .scroll((0, 0)); + frame.render_widget(paragraph, area); +} + /// Simple word wrap: splits text at word boundaries to fit max_width chars per line fn textwrap(text: &str, max_width: usize) -> Vec { let mut result = Vec::new(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e87673c..2ec552a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -12,6 +12,7 @@ pub enum Focus { Tabs, TaskList, Detail, + Calendar, } #[derive(Debug, Clone, PartialEq)] @@ -46,6 +47,8 @@ pub struct AppView<'a> { pub task_list_scroll: u16, pub detail_scroll: u16, pub notes_scroll: u16, + pub calendar_events: &'a [CalendarEvent], + pub calendar_scroll: u16, pub auth_error: Option<&'a str>, pub sync_stats: &'a SyncStats, } @@ -74,16 +77,30 @@ pub fn draw(frame: &mut Frame, view: AppView) { .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(body_area); + let left_col = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(8)]) + .split(body_layout[0]); + let is_task_list_focused = view.focus == Focus::TaskList; render_task_list( frame, - body_layout[0], + left_col[0], view.tasks, view.selected_task, is_task_list_focused, view.task_list_scroll, ); + let is_calendar_focused = view.focus == Focus::Calendar; + render_calendar_panel( + frame, + left_col[1], + view.calendar_events, + is_calendar_focused, + view.calendar_scroll, + ); + let is_detail_focused = view.focus == Focus::Detail; render_detail( frame,