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:
+14
-3
@@ -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();
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user