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
+14 -3
View File
@@ -23,6 +23,7 @@ pub struct SyncStats {
pub struct App {
pub lists: Vec<TaskList>,
pub tasks: Vec<Task>,
pub calendar_events: Vec<CalendarEvent>,
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<Db>,
#[allow(dead_code)]
pub api_client: Arc<ApiClient>,
@@ -66,7 +68,7 @@ pub enum SyncCommand {
}
impl App {
pub fn new(db: Arc<Db>, api_client: Arc<ApiClient>, sync_tx: mpsc::Sender<SyncCommand>) -> Self {
pub fn new(db: Arc<Db>, api_client: Arc<ApiClient>, sync_tx: mpsc::Sender<SyncCommand>, _calendar_events_shared: Arc<tokio::sync::Mutex<Vec<CalendarEvent>>>) -> 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();
+8
View File
@@ -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<chrono::NaiveDateTime>,
pub end: Option<chrono::NaiveDateTime>,
pub location: Option<String>,
}
+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
}
}
+31 -2
View File
@@ -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::<CalendarEvent>::new()));
let (sync_tx, mut sync_rx) = tokio::sync::mpsc::channel::<SyncCommand>(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<ApiClient>,
network_status: Arc<Mutex<NetworkStatus>>,
sync_stats: Arc<Mutex<SyncStats>>,
calendar_events: Arc<Mutex<Vec<CalendarEvent>>>,
rx: &mut tokio::sync::mpsc::Receiver<SyncCommand>,
) {
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<ApiClient>,
events_shared: &Arc<Mutex<Vec<CalendarEvent>>>,
network_status: &Arc<Mutex<NetworkStatus>>,
) {
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;
}
}
}
+71
View File
@@ -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<Line> = Vec::new();
let mut current_date: Option<chrono::NaiveDate> = 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<Line> = 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<String> {
let mut result = Vec::new();
+18 -1
View File
@@ -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,