Feature 3: Read-only Google Calendar panel

- CalendarEvent model with summary, start, end, location
- New scope calendar.readonly in SCOPES
- fetch_upcoming_events() in ApiClient (15 events, next 7 days)
- Focus::Calendar variant, Tab cycle includes Calendar
- Body layout: left column split into Tasks (flex) + Calendar (8)
- render_calendar_panel with day headers and scroll
- refresh_calendar() called on initial sync and Ctrl+R
- Up/Down scroll Calendar panel when focused
This commit is contained in:
Ruben Rosario
2026-06-21 17:18:22 +01:00
parent fa03a30a31
commit 7946b0f102
6 changed files with 202 additions and 7 deletions
+60 -1
View File
@@ -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<Path>) -> Result<Self, ApiError> {
@@ -455,4 +458,60 @@ impl ApiClient {
Ok(())
}
pub async fn fetch_upcoming_events(&self, max_results: u32) -> Result<Vec<CalendarEvent>, 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::<Vec<_>>()
})
.unwrap_or_default();
Ok(items)
}
}
fn parse_calendar_time(obj: &serde_json::Value) -> Option<chrono::NaiveDateTime> {
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
}
}