Compare commits

...

10 Commits

Author SHA1 Message Date
Ruben Rosario 83762720a1 Fix move to existing list: sync_queue list_id update + PickList filter
- update_list_id now also updates sync_queue table
- PickList filters out local-only UUID lists (only shows server IDs)
2026-06-21 19:26:22 +01:00
Ruben Rosario d669ca5c05 Enhanced BulkAction: 7 options, arrow navigation, PickList, visual selection
- Added: Mark as uncomplete, Set due Tomorrow, Set due Next Week
- Added: Move to existing list (PickList popup)
- ↑/↓ navigate options, Enter executes, 1-7 for number shortcuts
- Selected tasks now show bg(DarkGray) for clearer visual feedback
- BulkAction popup with 7 options, PickList popup for list selection
2026-06-21 19:21:04 +01:00
Ruben Rosario 2fb550229e Multi-select tasks with Shift+Arrows and bulk actions
- Shift+Up/Down extends selection in task list
- Enter opens BulkAction popup with 3 options:
  1. Mark as completed
  2. Set due date to Today
  3. Move to new list (creates list, copies tasks, deletes originals)
- Plain Up/Down clears selection, Escape clears it
- Selected items highlighted in Yellow
2026-06-21 18:59:27 +01:00
Ruben Rosario 10a8d1d75e Calendar: independent week scrolling and weekend colors
- calendar_scroll replaced by per-week calendar_scrolls[4] + active_week
- Tab cycles through weeks within Calendar, Left/Right switch week
- Up/Down scroll only the active week independently
- Sat/Sun rendered in Magenta, weekdays in Cyan, today in Yellow
2026-06-21 18:57:07 +01:00
Ruben Rosario 00fec516ac Tolerate 404 on delete operations
- delete_task returns Ok(()) on 404 (already deleted)
- delete_list returns Ok(()) on 404 (already deleted)
2026-06-21 18:29:59 +01:00
Ruben Rosario 9649ca96b0 Fix list deletion sync: SyncAction::DeleteList
- Added DeleteList variant to SyncAction enum
- Added ApiClient::delete_list() calling DELETE /users/@me/lists/{id}
- List deletion uses DeleteList action (not Delete/delete_task)
- Sync engine handles DeleteList calling api.delete_list()
2026-06-21 18:27:53 +01:00
Ruben Rosario a35eab35af Fix sync: CreateList action + server ID mapping
- Added SyncAction::CreateList variant
- create_task returns server Task, added create_list API
- Sync engine processes CreateList first, updates IDs in batch
- After Create/CreateList success, local IDs updated to server IDs
2026-06-21 18:14:24 +01:00
Ruben Rosario 3379cbd057 n+l leader key for new list creation
- n is now a leader key (like t for dates)
- n + l opens Input popup to name and create a new list
- n + n opens EditTask popup to create a new task
- Popup::Input Enter now creates a list regardless of focus
- Removed immediate n behavior (list/task creation)
2026-06-21 18:04:37 +01:00
Ruben Rosario 7ebafec3c0 Calendar panel: full-width layout with 4 weekly columns
- Moved Calendar from body left column (8 lines) to full-width
  row between body and status bar (12 lines)
- Calendar splits into 4 horizontal panels, each showing one week
  starting from Monday of the current week
- Day headers in Cyan (Yellow for today), events in White
- Removed old date-grouped event list rendering
- Body layout simplified to single horizontal split (Tasks | Detail)
2026-06-21 18:01:49 +01:00
Ruben Rosario 822c335864 Fix token_has_all_scopes for yup-oauth2 v8 array format
yup-oauth2 v8 stores tokens as a JSON array of entries, each
with a 'scopes' array field. Updated the parser to iterate
over entries and check for matching scopes.
2026-06-21 17:35:31 +01:00
7 changed files with 907 additions and 258 deletions
+439 -71
View File
@@ -1,3 +1,4 @@
use std::collections::BTreeSet;
use std::sync::mpsc as std_mpsc;
use std::sync::Arc;
@@ -39,7 +40,8 @@ pub struct App {
pub task_list_scroll: u16,
pub detail_scroll: u16,
pub notes_scroll: u16,
pub calendar_scroll: u16,
pub calendar_scrolls: [u16; 4],
pub calendar_active_week: usize,
pub db: Arc<Db>,
#[allow(dead_code)]
pub api_client: Arc<ApiClient>,
@@ -49,6 +51,12 @@ pub struct App {
last_sync_version: u64,
editing_task_id: Option<String>,
pending_date_key: bool,
pending_new_key: bool,
pending_bulk_move: bool,
pub selected_tasks: BTreeSet<usize>,
pub bulk_action_selected: usize,
pub popup_list_indices: Vec<(String, String)>,
pub popup_list_selected: usize,
auth_tx: std_mpsc::Sender<AuthEvent>,
auth_rx: std_mpsc::Receiver<AuthEvent>,
sync_tx: mpsc::Sender<SyncCommand>,
@@ -109,7 +117,8 @@ impl App {
task_list_scroll: 0,
detail_scroll: 0,
notes_scroll: 0,
calendar_scroll: 0,
calendar_scrolls: [0; 4],
calendar_active_week: 0,
db,
api_client,
needs_auth: !has_token,
@@ -118,6 +127,12 @@ impl App {
last_sync_version: 0,
editing_task_id: None,
pending_date_key: false,
pending_new_key: false,
pending_bulk_move: false,
selected_tasks: BTreeSet::new(),
bulk_action_selected: 0,
popup_list_indices: Vec::new(),
popup_list_selected: 0,
auth_tx,
auth_rx,
sync_tx,
@@ -246,6 +261,33 @@ impl App {
}
pub fn handle_key(&mut self, key: KeyEvent) {
if self.pending_new_key {
self.pending_new_key = false;
match key.code {
KeyCode::Char('l') | KeyCode::Char('L') => {
self.editing_task_id = None;
self.popup_input.clear();
self.popup_cursor = 0;
self.show_popup = Some(Popup::Input);
return;
}
KeyCode::Char('n') | KeyCode::Char('N') => {
if !self.lists.is_empty() {
self.editing_task_id = None;
self.popup_input.clear();
self.popup_cursor = 0;
self.popup_secondary.clear();
self.popup_secondary_cursor = 0;
self.edit_field = 0;
self.notes_scroll = 0;
self.show_popup = Some(Popup::EditTask { field: 0 });
}
return;
}
_ => {}
}
}
if let Some(ref popup) = self.show_popup.clone() {
self.handle_popup_key(key, popup);
return;
@@ -283,12 +325,40 @@ impl App {
match key.code {
KeyCode::Tab => {
self.focus = match self.focus {
Focus::Tabs => Focus::TaskList,
Focus::TaskList => Focus::Detail,
Focus::Detail => Focus::Calendar,
Focus::Calendar => Focus::Tabs,
};
match self.focus {
Focus::Calendar => {
if self.calendar_active_week < 3 {
self.calendar_active_week += 1;
} else {
self.calendar_active_week = 0;
self.focus = Focus::Tabs;
}
}
_ => {
self.focus = match self.focus {
Focus::Tabs => Focus::TaskList,
Focus::TaskList => Focus::Detail,
Focus::Detail => Focus::Calendar,
_ => Focus::Tabs,
};
}
}
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => {
if self.focus == Focus::TaskList && !self.tasks.is_empty() && self.selected_task > 0 {
self.selected_tasks.insert(self.selected_task);
self.selected_task -= 1;
self.selected_tasks.insert(self.selected_task);
self.task_list_scroll = self.task_list_scroll.saturating_sub(1);
}
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => {
if self.focus == Focus::TaskList && !self.tasks.is_empty() && self.selected_task + 1 < self.tasks.len() {
self.selected_tasks.insert(self.selected_task);
self.selected_task += 1;
self.selected_tasks.insert(self.selected_task);
self.task_list_scroll += 1;
}
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
if self.focus == Focus::TaskList && !self.tasks.is_empty() {
@@ -303,6 +373,7 @@ impl App {
KeyCode::Up => match self.focus {
Focus::TaskList => {
if self.selected_task > 0 {
self.clear_selection();
self.selected_task -= 1;
self.task_list_scroll = self.task_list_scroll.saturating_sub(1);
}
@@ -311,13 +382,14 @@ impl App {
self.detail_scroll = self.detail_scroll.saturating_sub(1);
}
Focus::Calendar => {
self.calendar_scroll = self.calendar_scroll.saturating_sub(1);
self.calendar_scrolls[self.calendar_active_week] = self.calendar_scrolls[self.calendar_active_week].saturating_sub(1);
}
_ => {}
},
KeyCode::Down => match self.focus {
Focus::TaskList => {
if self.selected_task + 1 < self.tasks.len() {
self.clear_selection();
self.selected_task += 1;
self.task_list_scroll += 1;
}
@@ -326,43 +398,45 @@ impl App {
self.detail_scroll += 1;
}
Focus::Calendar => {
self.calendar_scroll += 1;
self.calendar_scrolls[self.calendar_active_week] = self.calendar_scrolls[self.calendar_active_week].saturating_add(1);
}
_ => {}
},
KeyCode::Right => {
if self.focus == Focus::Tabs && !self.lists.is_empty() {
if self.selected_list + 1 < self.lists.len() {
self.selected_list += 1;
self.load_tasks();
match self.focus {
Focus::Tabs => {
if !self.lists.is_empty() && self.selected_list + 1 < self.lists.len() {
self.selected_list += 1;
self.load_tasks();
}
}
Focus::Calendar => {
if self.calendar_active_week < 3 {
self.calendar_active_week += 1;
}
}
_ => {}
}
}
KeyCode::Left => {
if self.focus == Focus::Tabs && !self.lists.is_empty() {
if self.selected_list > 0 {
self.selected_list -= 1;
self.load_tasks();
match self.focus {
Focus::Tabs => {
if !self.lists.is_empty() && self.selected_list > 0 {
self.selected_list -= 1;
self.load_tasks();
}
}
Focus::Calendar => {
if self.calendar_active_week > 0 {
self.calendar_active_week -= 1;
}
}
_ => {}
}
}
KeyCode::Char('n') | KeyCode::Char('N') => {
if !self.needs_auth {
if self.focus == Focus::Tabs {
self.editing_task_id = None;
self.popup_input.clear();
self.popup_cursor = 0;
self.show_popup = Some(Popup::Input);
} else if !self.lists.is_empty() {
self.editing_task_id = None;
self.popup_input.clear();
self.popup_cursor = 0;
self.popup_secondary.clear();
self.popup_secondary_cursor = 0;
self.edit_field = 0;
self.notes_scroll = 0;
self.show_popup = Some(Popup::EditTask { field: 0 });
}
self.pending_new_key = true;
}
}
KeyCode::Char('d') | KeyCode::Char('D') => {
@@ -392,24 +466,32 @@ impl App {
if self.focus == Focus::Detail && !self.tasks.is_empty() {
self.show_popup = Some(Popup::DatePicker);
} else if self.focus == Focus::TaskList && !self.tasks.is_empty() {
let task = &mut self.tasks[self.selected_task];
task.status = match task.status {
TaskStatus::Completed => TaskStatus::NeedsAction,
TaskStatus::NeedsAction => TaskStatus::Completed,
};
self.db.update_task(task).ok();
self.db.push_sync(
SyncAction::Update,
&task.id,
&task.list_id,
&serde_json::to_string(task).unwrap_or_default(),
).ok();
self.trigger_sync();
self.load_tasks();
if !self.selected_tasks.is_empty() {
self.show_popup = Some(Popup::BulkAction);
} else {
let task = &mut self.tasks[self.selected_task];
task.status = match task.status {
TaskStatus::Completed => TaskStatus::NeedsAction,
TaskStatus::NeedsAction => TaskStatus::Completed,
};
self.db.update_task(task).ok();
self.db.push_sync(
SyncAction::Update,
&task.id,
&task.list_id,
&serde_json::to_string(task).unwrap_or_default(),
).ok();
self.trigger_sync();
self.load_tasks();
}
}
}
KeyCode::Esc => {
self.show_popup = None;
if self.show_popup.is_some() {
self.show_popup = None;
} else {
self.clear_selection();
}
}
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.trigger_full_sync();
@@ -447,28 +529,33 @@ impl App {
},
Popup::Input => match key.code {
KeyCode::Esc => {
self.pending_bulk_move = false;
self.show_popup = None;
}
KeyCode::Enter => {
let input = self.popup_input.trim().to_string();
if !input.is_empty() {
if self.focus == Focus::Tabs {
let list = TaskList {
id: uuid_v4(),
title: input,
};
self.db.insert_list(&list).ok();
self.db.push_sync(
SyncAction::Create,
&list.id,
&list.id,
&serde_json::to_string(&list).unwrap_or_default(),
).ok();
self.trigger_sync();
self.load_lists();
}
if self.pending_bulk_move && !input.is_empty() {
self.pending_bulk_move = false;
self.bulk_move_to_new_list(&input);
self.show_popup = None;
} else if !input.is_empty() {
let list = TaskList {
id: uuid_v4(),
title: input,
};
self.db.insert_list(&list).ok();
self.db.push_sync(
SyncAction::CreateList,
&list.id,
&list.id,
&serde_json::to_string(&list).unwrap_or_default(),
).ok();
self.trigger_sync();
self.load_lists();
self.show_popup = None;
} else {
self.show_popup = None;
}
self.show_popup = None;
}
KeyCode::Char(c) => {
self.popup_input.insert(self.popup_cursor, c);
@@ -726,12 +813,12 @@ impl App {
if self.selected_list < self.lists.len() {
let list_id = self.lists[self.selected_list].id.clone();
self.db.delete_list(&list_id).ok();
self.db.push_sync(
SyncAction::Delete,
&list_id,
&list_id,
"",
).ok();
self.db.push_sync(
SyncAction::DeleteList,
&list_id,
&list_id,
"",
).ok();
self.trigger_sync();
self.load_lists();
if self.selected_list >= self.lists.len() {
@@ -764,6 +851,282 @@ impl App {
}
_ => {}
},
Popup::BulkAction => match key.code {
KeyCode::Esc => {
self.show_popup = None;
}
KeyCode::Up => {
self.bulk_action_selected = self.bulk_action_selected.saturating_sub(1);
}
KeyCode::Down => {
if self.bulk_action_selected < 6 {
self.bulk_action_selected += 1;
}
}
KeyCode::Enter => {
let action = self.bulk_action_selected;
self.execute_bulk_action(action);
if action <= 4 {
self.show_popup = None;
}
}
KeyCode::Char(c) => {
if let Some(n) = c.to_digit(10) {
let idx = n as usize - 1;
if idx <= 6 {
self.execute_bulk_action(idx);
if idx <= 4 {
self.show_popup = None;
}
}
}
}
_ => {}
},
Popup::PickList => match key.code {
KeyCode::Esc => {
self.show_popup = Some(Popup::BulkAction);
}
KeyCode::Up => {
self.popup_list_selected = self.popup_list_selected.saturating_sub(1);
}
KeyCode::Down => {
if self.popup_list_selected + 1 < self.popup_list_indices.len() {
self.popup_list_selected += 1;
}
}
KeyCode::Enter => {
if !self.popup_list_indices.is_empty() {
let list_id = self.popup_list_indices[self.popup_list_selected].1.clone();
self.bulk_move_to_existing_list(&list_id);
self.show_popup = None;
}
}
_ => {}
},
}
}
fn bulk_mark_completed(&mut self) {
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
for &i in &indices {
if i >= self.tasks.len() { continue; }
let task = &mut self.tasks[i];
task.status = TaskStatus::Completed;
self.db.update_task(task).ok();
self.db.push_sync(
SyncAction::Update,
&task.id,
&task.list_id,
&serde_json::to_string(task).unwrap_or_default(),
).ok();
}
self.clear_selection();
self.trigger_sync();
self.load_tasks();
}
fn bulk_set_due_today(&mut self) {
let now = chrono::Local::now().naive_local();
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
for &i in &indices {
if i >= self.tasks.len() { continue; }
let task = &mut self.tasks[i];
task.due = Some(now);
self.db.update_task(task).ok();
self.db.push_sync(
SyncAction::Update,
&task.id,
&task.list_id,
&serde_json::to_string(task).unwrap_or_default(),
).ok();
}
self.clear_selection();
self.trigger_sync();
self.load_tasks();
}
fn bulk_move_to_new_list(&mut self, list_name: &str) {
let list = TaskList {
id: uuid_v4(),
title: list_name.to_string(),
};
self.db.insert_list(&list).ok();
self.db.push_sync(
SyncAction::CreateList,
&list.id,
&list.id,
&serde_json::to_string(&list).unwrap_or_default(),
).ok();
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
for &i in &indices {
if i >= self.tasks.len() { continue; }
let original = &self.tasks[i];
let new_task = Task {
id: uuid_v4(),
list_id: list.id.clone(),
title: original.title.clone(),
notes: original.notes.clone(),
status: original.status.clone(),
due: original.due,
position: 0,
created_at: None,
updated_at: None,
};
self.db.insert_task(&new_task).ok();
self.db.push_sync(
SyncAction::Create,
&new_task.id,
&list.id,
&serde_json::to_string(&new_task).unwrap_or_default(),
).ok();
// Delete original
self.db.delete_task(&original.id).ok();
self.db.push_sync(
SyncAction::Delete,
&original.id,
&original.list_id,
"",
).ok();
}
self.clear_selection();
self.trigger_sync();
self.load_lists();
// Switch to the new list
if let Some(pos) = self.lists.iter().position(|l| l.id == list.id) {
self.selected_list = pos;
}
self.load_tasks();
}
fn bulk_mark_uncomplete(&mut self) {
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
for &i in &indices {
if i >= self.tasks.len() { continue; }
let task = &mut self.tasks[i];
task.status = TaskStatus::NeedsAction;
self.db.update_task(task).ok();
self.db.push_sync(
SyncAction::Update,
&task.id,
&task.list_id,
&serde_json::to_string(task).unwrap_or_default(),
).ok();
}
self.clear_selection();
self.trigger_sync();
self.load_tasks();
}
fn bulk_set_due_tomorrow(&mut self) {
let tomorrow = chrono::Local::now().naive_local() + chrono::Duration::days(1);
let time = NaiveTime::from_hms_opt(9, 0, 0).unwrap();
let due = chrono::NaiveDateTime::new(tomorrow.date(), time);
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
for &i in &indices {
if i >= self.tasks.len() { continue; }
let task = &mut self.tasks[i];
task.due = Some(due);
self.db.update_task(task).ok();
self.db.push_sync(
SyncAction::Update,
&task.id,
&task.list_id,
&serde_json::to_string(task).unwrap_or_default(),
).ok();
}
self.clear_selection();
self.trigger_sync();
self.load_tasks();
}
fn bulk_set_due_next_week(&mut self) {
let next_week = chrono::Local::now().naive_local() + chrono::Duration::days(7);
let time = NaiveTime::from_hms_opt(9, 0, 0).unwrap();
let due = chrono::NaiveDateTime::new(next_week.date(), time);
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
for &i in &indices {
if i >= self.tasks.len() { continue; }
let task = &mut self.tasks[i];
task.due = Some(due);
self.db.update_task(task).ok();
self.db.push_sync(
SyncAction::Update,
&task.id,
&task.list_id,
&serde_json::to_string(task).unwrap_or_default(),
).ok();
}
self.clear_selection();
self.trigger_sync();
self.load_tasks();
}
fn bulk_move_to_existing_list(&mut self, target_list_id: &str) {
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
for &i in &indices {
if i >= self.tasks.len() { continue; }
let original = &self.tasks[i];
let new_task = Task {
id: uuid_v4(),
list_id: target_list_id.to_string(),
title: original.title.clone(),
notes: original.notes.clone(),
status: original.status.clone(),
due: original.due,
position: 0,
created_at: None,
updated_at: None,
};
self.db.insert_task(&new_task).ok();
self.db.push_sync(
SyncAction::Create,
&new_task.id,
target_list_id,
&serde_json::to_string(&new_task).unwrap_or_default(),
).ok();
self.db.delete_task(&original.id).ok();
self.db.push_sync(
SyncAction::Delete,
&original.id,
&original.list_id,
"",
).ok();
}
self.clear_selection();
self.trigger_sync();
if let Some(pos) = self.lists.iter().position(|l| l.id == target_list_id) {
self.selected_list = pos;
}
self.load_tasks();
}
fn execute_bulk_action(&mut self, action_idx: usize) {
match action_idx {
0 => self.bulk_mark_completed(),
1 => self.bulk_mark_uncomplete(),
2 => self.bulk_set_due_today(),
3 => self.bulk_set_due_tomorrow(),
4 => self.bulk_set_due_next_week(),
5 => {
self.popup_input.clear();
self.popup_cursor = 0;
self.pending_bulk_move = true;
self.show_popup = Some(Popup::Input);
}
6 => {
self.popup_list_indices = self.lists.iter()
.filter(|l| !l.id.contains('-'))
.map(|l| (l.title.clone(), l.id.clone()))
.collect();
self.popup_list_selected = 0;
self.show_popup = Some(Popup::PickList);
}
_ => {}
}
}
@@ -813,6 +1176,7 @@ impl App {
}
fn load_tasks(&mut self) {
self.selected_tasks.clear();
if self.selected_list < self.lists.len() {
let mut tasks = self.db.get_tasks(&self.lists[self.selected_list].id);
sort_tasks(&mut tasks);
@@ -826,6 +1190,10 @@ impl App {
self.selected_task = 0;
}
}
fn clear_selection(&mut self) {
self.selected_tasks.clear();
}
}
fn sort_tasks(tasks: &mut Vec<Task>) {
+2
View File
@@ -32,6 +32,8 @@ pub enum SyncAction {
Update,
Delete,
Reorder,
CreateList,
DeleteList,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+133 -94
View File
@@ -78,15 +78,24 @@ impl ApiClient {
Ok(c) => c,
Err(_) => return false,
};
let value: serde_json::Value = match serde_json::from_str(&content) {
let entries: Vec<serde_json::Value> = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return false,
};
let scope = match value["scope"].as_str() {
Some(s) => s,
None => return false,
};
SCOPES.iter().all(|s| scope.contains(s))
for entry in &entries {
let scopes = match entry["scopes"].as_array() {
Some(s) => s,
None => continue,
};
let has_all: Vec<&str> = scopes
.iter()
.filter_map(|s| s.as_str())
.collect();
if SCOPES.iter().all(|s| has_all.contains(s)) {
return true;
}
}
false
}
pub async fn start_and_wait_for_auth(&self) -> Result<(), ApiError> {
@@ -187,47 +196,7 @@ impl ApiClient {
let tasks = all_items
.iter()
.enumerate()
.map(|(i, item)| {
let due_str = item["due"].as_str().and_then(|s| {
chrono::NaiveDateTime::parse_from_str(
&s.replace("T", " ")
.replace("Z", "")
.chars()
.take(16)
.collect::<String>(),
"%Y-%m-%d %H:%M",
)
.ok()
});
let updated = item["updated"].as_str().and_then(|s| {
chrono::NaiveDateTime::parse_from_str(
&s.replace("T", " ")
.replace("Z", "")
.chars()
.take(19)
.collect::<String>(),
"%Y-%m-%d %H:%M:%S",
)
.ok()
});
Task {
id: item["id"].as_str().unwrap_or("").to_string(),
list_id: list_id.to_string(),
title: item["title"].as_str().unwrap_or("").to_string(),
notes: item["notes"].as_str().map(|s| s.to_string()),
status: if item["status"].as_str() == Some("completed") {
TaskStatus::Completed
} else {
TaskStatus::NeedsAction
},
due: due_str,
position: i as i64,
created_at: None,
updated_at: updated,
}
})
.map(|(i, item)| parse_task_value(item, list_id, i))
.collect();
Ok(tasks)
@@ -281,53 +250,13 @@ impl ApiClient {
let tasks = all_items
.iter()
.enumerate()
.map(|(i, item)| {
let due_str = item["due"].as_str().and_then(|s| {
chrono::NaiveDateTime::parse_from_str(
&s.replace("T", " ")
.replace("Z", "")
.chars()
.take(16)
.collect::<String>(),
"%Y-%m-%d %H:%M",
)
.ok()
});
let updated = item["updated"].as_str().and_then(|s| {
chrono::NaiveDateTime::parse_from_str(
&s.replace("T", " ")
.replace("Z", "")
.chars()
.take(19)
.collect::<String>(),
"%Y-%m-%d %H:%M:%S",
)
.ok()
});
Task {
id: item["id"].as_str().unwrap_or("").to_string(),
list_id: list_id.to_string(),
title: item["title"].as_str().unwrap_or("").to_string(),
notes: item["notes"].as_str().map(|s| s.to_string()),
status: if item["status"].as_str() == Some("completed") {
TaskStatus::Completed
} else {
TaskStatus::NeedsAction
},
due: due_str,
position: i as i64,
created_at: None,
updated_at: updated,
}
})
.map(|(i, item)| parse_task_value(item, list_id, i))
.collect();
Ok(tasks)
}
pub async fn create_task(&self, list_id: &str, task: &Task) -> Result<(), ApiError> {
pub async fn create_task(&self, list_id: &str, task: &Task) -> Result<Task, ApiError> {
let token = self.get_token().await?;
let mut body = serde_json::json!({
@@ -360,13 +289,48 @@ impl ApiClient {
.map_err(|e| ApiError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(ApiError::Api(format!(
"Create failed: {}",
resp.status()
)));
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
return Err(ApiError::Api(format!("Create failed: {} - {}", status, body_text)));
}
Ok(())
let data: serde_json::Value = resp
.json()
.await
.map_err(|e| ApiError::Api(format!("Create parse error: {}", e)))?;
Ok(parse_task_value(&data, list_id, 0))
}
pub async fn create_list(&self, title: &str) -> Result<TaskList, ApiError> {
let token = self.get_token().await?;
let body = serde_json::json!({ "title": title });
let resp = self
.client
.post("https://tasks.googleapis.com/tasks/v1/users/@me/lists")
.bearer_auth(&token)
.json(&body)
.send()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
return Err(ApiError::Api(format!("Create list failed: {} - {}", status, body_text)));
}
let data: serde_json::Value = resp
.json()
.await
.map_err(|e| ApiError::Api(format!("Create list parse error: {}", e)))?;
Ok(TaskList {
id: data["id"].as_str().unwrap_or("").to_string(),
title: data["title"].as_str().unwrap_or("").to_string(),
})
}
pub async fn update_task(&self, list_id: &str, task: &Task) -> Result<(), ApiError> {
@@ -429,6 +393,10 @@ impl ApiClient {
.map_err(|e| ApiError::Network(e.to_string()))?;
if !resp.status().is_success() {
// 404 means already deleted — treat as success
if resp.status() == 404 {
return Ok(());
}
return Err(ApiError::Api(format!(
"Delete failed: {}",
resp.status()
@@ -438,6 +406,35 @@ impl ApiClient {
Ok(())
}
pub async fn delete_list(&self, list_id: &str) -> Result<(), ApiError> {
let token = self.get_token().await?;
let url = format!(
"https://tasks.googleapis.com/tasks/v1/users/@me/lists/{}",
list_id
);
let resp = self
.client
.delete(&url)
.bearer_auth(&token)
.send()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status();
// 404 means already deleted — treat as success
if status == 404 {
return Ok(());
}
let body = resp.text().await.unwrap_or_default();
return Err(ApiError::Api(format!("Delete list failed: {} - {}", status, body)));
}
Ok(())
}
pub async fn move_task(
&self,
list_id: &str,
@@ -521,6 +518,48 @@ impl ApiClient {
}
}
fn parse_task_value(item: &serde_json::Value, list_id: &str, position: usize) -> Task {
let due_str = item["due"].as_str().and_then(|s| {
chrono::NaiveDateTime::parse_from_str(
&s.replace("T", " ")
.replace("Z", "")
.chars()
.take(16)
.collect::<String>(),
"%Y-%m-%d %H:%M",
)
.ok()
});
let updated = item["updated"].as_str().and_then(|s| {
chrono::NaiveDateTime::parse_from_str(
&s.replace("T", " ")
.replace("Z", "")
.chars()
.take(19)
.collect::<String>(),
"%Y-%m-%d %H:%M:%S",
)
.ok()
});
Task {
id: item["id"].as_str().unwrap_or("").to_string(),
list_id: list_id.to_string(),
title: item["title"].as_str().unwrap_or("").to_string(),
notes: item["notes"].as_str().map(|s| s.to_string()),
status: if item["status"].as_str() == Some("completed") {
TaskStatus::Completed
} else {
TaskStatus::NeedsAction
},
due: due_str,
position: position as i64,
created_at: None,
updated_at: updated,
}
}
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())
+49
View File
@@ -281,6 +281,8 @@ impl Db {
SyncAction::Update => "Update",
SyncAction::Delete => "Delete",
SyncAction::Reorder => "Reorder",
SyncAction::CreateList => "CreateList",
SyncAction::DeleteList => "DeleteList",
};
let conn = self.conn.lock().unwrap();
conn.execute(
@@ -306,6 +308,51 @@ impl Db {
count > 0
}
pub fn update_task_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE tasks SET id = ?1 WHERE id = ?2",
params![new_id, old_id],
)?;
Ok(())
}
pub fn update_sync_task_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE sync_queue SET task_id = ?1 WHERE task_id = ?2",
params![new_id, old_id],
)?;
Ok(())
}
pub fn update_list_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE task_lists SET id = ?1 WHERE id = ?2",
params![new_id, old_id],
)?;
conn.execute(
"UPDATE tasks SET list_id = ?1 WHERE list_id = ?2",
params![new_id, old_id],
)?;
conn.execute(
"UPDATE sync_queue SET list_id = ?1 WHERE list_id = ?2",
params![new_id, old_id],
)?;
Ok(())
}
#[allow(dead_code)]
pub fn update_sync_list_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE sync_queue SET list_id = ?1 WHERE list_id = ?2",
params![new_id, old_id],
)?;
Ok(())
}
pub fn drain_sync(&self) -> Vec<SyncQueueItem> {
let conn = self.conn.lock().unwrap();
let items: Vec<SyncQueueItem> = {
@@ -319,6 +366,8 @@ impl Db {
"\"Update\"" | "Update" => SyncAction::Update,
"\"Delete\"" | "Delete" => SyncAction::Delete,
"\"Reorder\"" | "Reorder" => SyncAction::Reorder,
"\"CreateList\"" | "CreateList" => SyncAction::CreateList,
"\"DeleteList\"" | "DeleteList" => SyncAction::DeleteList,
_ => SyncAction::Update,
};
Ok(SyncQueueItem {
+60 -3
View File
@@ -150,9 +150,14 @@ 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,
calendar_scrolls: &app.calendar_scrolls,
calendar_active_week: app.calendar_active_week,
auth_error: app.auth_error.as_deref(),
sync_stats: &app.sync_stats,
selected_tasks: &app.selected_tasks,
bulk_action_selected: app.bulk_action_selected,
popup_list_indices: &app.popup_list_indices,
popup_list_selected: app.popup_list_selected,
};
draw(frame, view);
})?;
@@ -246,7 +251,7 @@ async fn push_sync(
return;
}
let items = db.drain_sync();
let mut items = db.drain_sync();
let count = items.len();
if count == 0 {
@@ -257,7 +262,50 @@ async fn push_sync(
*network_status.lock().await = NetworkStatus::Syncing;
let mut all_ok = true;
// First pass: CreateList items (so list IDs are updated before task operations)
for i in 0..items.len() {
if items[i].action != SyncAction::CreateList {
continue;
}
let list: TaskList = serde_json::from_str(&items[i].payload).unwrap_or_else(|_| TaskList {
id: items[i].task_id.clone(),
title: String::new(),
});
match api.create_list(&list.title).await {
Ok(server_list) => {
if server_list.id != items[i].task_id {
let _ = db.update_list_id(&items[i].task_id, &server_list.id);
// Update list_id in remaining items of this batch
for j in (i + 1)..items.len() {
if items[j].list_id == items[i].task_id {
items[j].list_id = server_list.id.clone();
}
}
}
}
Err(err) => {
eprintln!("[task_app] Sync failed (retry {}/{}): action=CreateList list={} error={}",
items[i].retries, MAX_SYNC_RETRIES, items[i].task_id, err);
if items[i].retries < MAX_SYNC_RETRIES {
let _ = db.push_sync_with_retry(
SyncAction::CreateList,
&items[i].task_id,
&items[i].list_id,
&items[i].payload,
items[i].retries + 1,
);
}
all_ok = false;
}
}
}
// Second pass: everything else
for item in &items {
if item.action == SyncAction::CreateList {
continue;
}
let result = match item.action {
SyncAction::Create => {
let task = serde_json::from_str::<Task>(&item.payload).unwrap_or_else(|_| Task {
@@ -271,7 +319,12 @@ async fn push_sync(
created_at: None,
updated_at: None,
});
api.create_task(&item.list_id, &task).await
api.create_task(&item.list_id, &task).await.map(|server_task| {
if server_task.id != item.task_id {
let _ = db.update_task_id(&item.task_id, &server_task.id);
let _ = db.update_sync_task_id(&item.task_id, &server_task.id);
}
})
}
SyncAction::Update => {
let task = serde_json::from_str::<Task>(&item.payload).unwrap_or_else(|_| Task {
@@ -293,6 +346,10 @@ async fn push_sync(
SyncAction::Reorder => {
api.move_task(&item.list_id, &item.task_id, None, None).await
}
SyncAction::DeleteList => {
api.delete_list(&item.task_id).await
}
_ => Ok(()),
};
if let Err(err) = result {
+197 -73
View File
@@ -1,3 +1,7 @@
use std::collections::BTreeSet;
use chrono::Datelike;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs, Wrap};
@@ -74,6 +78,7 @@ pub fn render_task_list(
selected: usize,
focused: bool,
_scroll: u16,
selected_tasks: &BTreeSet<usize>,
) {
let total = tasks.len();
let done = tasks.iter().filter(|t| t.status == TaskStatus::Completed).count();
@@ -83,7 +88,9 @@ pub fn render_task_list(
let items: Vec<ListItem> = tasks
.iter()
.map(|task| {
.enumerate()
.map(|(idx, task)| {
let is_selected = selected_tasks.contains(&idx);
let checkbox = match task.status {
TaskStatus::Completed => "[\u{2713}]",
TaskStatus::NeedsAction => "[ ]",
@@ -119,25 +126,29 @@ pub fn render_task_list(
content_width.saturating_sub(used)
};
let mut spans = vec![
Span::styled(
checkbox_str,
Style::default().fg(if task.status == TaskStatus::Completed {
Color::Green
let checkbox_style = if is_selected {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD).bg(Color::DarkGray)
} else if task.status == TaskStatus::Completed {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
let title_style = if is_selected {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD).bg(Color::DarkGray)
} else {
Style::default().fg(DETAIL_COLOR).add_modifier(
if task.status == TaskStatus::Completed {
Modifier::CROSSED_OUT
} else {
Color::DarkGray
}),
),
Span::styled(
display_title,
Style::default().fg(DETAIL_COLOR).add_modifier(
if task.status == TaskStatus::Completed {
Modifier::CROSSED_OUT
} else {
Modifier::empty()
},
),
),
Modifier::empty()
},
)
};
let mut spans = vec![
Span::styled(checkbox_str, checkbox_style),
Span::styled(display_title, title_style),
];
if !due_text.is_empty() {
@@ -496,6 +507,91 @@ pub fn render_confirm_popup(frame: &mut Frame, area: Rect) {
frame.render_widget(paragraph, popup_area);
}
pub fn render_bulk_action_popup(frame: &mut Frame, area: Rect, count: usize, selected: usize) {
let popup_area = centered_rect(55, 12, area);
frame.render_widget(Clear, popup_area);
let block = Block::default()
.borders(Borders::ALL)
.style(Style::default().bg(POPUP_BG))
.border_style(Style::default().fg(POPUP_BORDER))
.title(format!(" Bulk Actions ({} selected) ", count))
.title_alignment(Alignment::Left);
let options = [
"1. Mark as completed",
"2. Mark as uncomplete",
"3. Set due date to Today",
"4. Set due date to Tomorrow",
"5. Set due date to Next Week",
"6. Move to new list...",
"7. Move to existing list...",
];
let mut lines = vec![Line::from("")];
for (i, opt) in options.iter().enumerate() {
let style = if i == selected {
Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
lines.push(Line::from(Span::styled(
format!(" {}", opt),
style,
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" ↑/↓: navigate Enter:ok 1-7:shortcut Esc:cancel",
Style::default().fg(Color::DarkGray),
)));
let paragraph = Paragraph::new(Text::from(lines))
.block(block)
.alignment(Alignment::Left);
frame.render_widget(paragraph, popup_area);
}
pub fn render_pick_list_popup(
frame: &mut Frame,
area: Rect,
lists: &[(String, String)],
selected: usize,
) {
let popup_area = centered_rect(60, 10, area);
frame.render_widget(Clear, popup_area);
let block = Block::default()
.borders(Borders::ALL)
.style(Style::default().bg(POPUP_BG))
.border_style(Style::default().fg(POPUP_BORDER))
.title(" Select List ")
.title_alignment(Alignment::Left);
let mut lines = vec![Line::from("")];
for (i, (title, _)) in lists.iter().enumerate() {
let style = if i == selected {
Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
lines.push(Line::from(Span::styled(
format!(" {}", title),
style,
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" ↑/↓: navigate Enter:ok Esc:cancel",
Style::default().fg(Color::DarkGray),
)));
let paragraph = Paragraph::new(Text::from(lines))
.block(block)
.alignment(Alignment::Left);
frame.render_widget(paragraph, popup_area);
}
pub fn render_device_auth_popup(
frame: &mut Frame,
area: Rect,
@@ -600,70 +696,98 @@ pub fn render_calendar_panel(
area: Rect,
events: &[CalendarEvent],
focused: bool,
scroll: u16,
scrolls: &[u16; 4],
active_week: usize,
) {
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);
if area.width < 20 || area.height < 3 {
return;
}
let mut lines: Vec<Line> = Vec::new();
let mut current_date: Option<chrono::NaiveDate> = None;
let today = chrono::Local::now().naive_local().date();
let weekday = today.weekday().num_days_from_monday();
let this_monday = today - chrono::Duration::days(weekday as i64);
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"),
);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Ratio(1, 4); 4])
.split(area);
let has_events = events.iter().any(|e| {
e.start.map_or(false, |s| {
let d = s.date();
d >= this_monday && d < this_monday + chrono::Duration::days(28)
})
});
for week_idx in 0..4 {
let week_start = this_monday + chrono::Duration::weeks(week_idx as i64);
let week_title = format!(" W/C {} ", week_start.format("%d/%m"));
let col_area = cols[week_idx];
let border = if focused && week_idx == active_week { FOCUS_COLOR } else { Color::DarkGray };
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border))
.title(week_title)
.title_alignment(Alignment::Left);
if !has_events {
let msg = if week_idx == 0 {
" No upcoming events "
} else {
""
};
let paragraph = Paragraph::new(Text::from(Line::from(Span::styled(
msg,
Style::default().fg(Color::DarkGray),
))))
.block(block);
frame.render_widget(paragraph, col_area);
continue;
}
let mut lines: Vec<Line> = Vec::new();
for day_offset in 0..7 {
let day = week_start + chrono::Duration::days(day_offset);
let day_style = if day == today {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else if matches!(day.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
};
let day_label = format!(
" {} {}",
match day.weekday() {
chrono::Weekday::Mon => "Mon",
chrono::Weekday::Tue => "Tue",
chrono::Weekday::Wed => "Wed",
chrono::Weekday::Thu => "Thu",
chrono::Weekday::Fri => "Fri",
chrono::Weekday::Sat => "Sat",
chrono::Weekday::Sun => "Sun",
},
day.format("%d/%m")
);
lines.push(Line::from(Span::styled(day_label, day_style)));
for event in events.iter().filter(|e| e.start.map_or(false, |s| s.date() == day)) {
let time_str = event.start.map(|s| s.format("%H:%M").to_string()).unwrap_or_default();
let line_text = format!(" {} {}", time_str, event.summary);
lines.push(Line::from(Span::styled(
day_header,
Style::default().fg(Color::Gray),
line_text,
Style::default().fg(DETAIL_COLOR),
)));
}
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 paragraph = Paragraph::new(Text::from(lines))
.block(block)
.scroll((scrolls[week_idx], 0));
frame.render_widget(paragraph, col_area);
}
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
+27 -17
View File
@@ -1,5 +1,7 @@
pub mod components;
use std::collections::BTreeSet;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::Frame;
@@ -21,6 +23,8 @@ pub enum Popup {
EditTask { field: usize },
DatePicker,
ConfirmDelete,
BulkAction,
PickList,
DeviceAuth { url: String, code: String },
}
@@ -48,9 +52,14 @@ pub struct AppView<'a> {
pub detail_scroll: u16,
pub notes_scroll: u16,
pub calendar_events: &'a [CalendarEvent],
pub calendar_scroll: u16,
pub calendar_scrolls: &'a [u16; 4],
pub calendar_active_week: usize,
pub auth_error: Option<&'a str>,
pub sync_stats: &'a SyncStats,
pub selected_tasks: &'a BTreeSet<usize>,
pub bulk_action_selected: usize,
pub popup_list_indices: &'a [(String, String)],
pub popup_list_selected: usize,
}
pub fn draw(frame: &mut Frame, view: AppView) {
@@ -61,13 +70,15 @@ pub fn draw(frame: &mut Frame, view: AppView) {
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(12),
Constraint::Length(1),
])
.split(area);
let tabs_area = main_layout[0];
let body_area = main_layout[1];
let status_area = main_layout[2];
let calendar_area = main_layout[2];
let status_area = main_layout[3];
let is_tabs_focused = view.focus == Focus::Tabs;
render_tabs_bar(frame, tabs_area, view.lists, view.selected_list, is_tabs_focused);
@@ -77,28 +88,15 @@ 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,
left_col[0],
body_layout[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,
view.selected_tasks,
);
let is_detail_focused = view.focus == Focus::Detail;
@@ -110,6 +108,16 @@ pub fn draw(frame: &mut Frame, view: AppView) {
view.detail_scroll,
);
let is_calendar_focused = view.focus == Focus::Calendar;
render_calendar_panel(
frame,
calendar_area,
view.calendar_events,
is_calendar_focused,
view.calendar_scrolls,
view.calendar_active_week,
);
render_status_bar(frame, status_area, view.network_status, view.sync_stats);
if let Some(popup) = view.show_popup {
@@ -122,6 +130,8 @@ pub fn draw(frame: &mut Frame, view: AppView) {
),
Popup::DatePicker => render_date_picker(frame, area, view.draft_date),
Popup::ConfirmDelete => render_confirm_popup(frame, area),
Popup::BulkAction => render_bulk_action_popup(frame, area, view.selected_tasks.len(), view.bulk_action_selected),
Popup::PickList => render_pick_list_popup(frame, area, view.popup_list_indices, view.popup_list_selected),
Popup::DeviceAuth { url, code } => render_device_auth_popup(frame, area, url, code, view.auth_error),
}
}