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
+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();