Skip to content

Feat: add Upcoming view - #40

Merged
romaintb merged 1 commit into
mainfrom
feat/upcoming_view
Sep 12, 2025
Merged

Feat: add Upcoming view#40
romaintb merged 1 commit into
mainfrom
feat/upcoming_view

Conversation

@romaintb

@romaintb romaintb commented Sep 12, 2025

Copy link
Copy Markdown
Owner

while here, fixed the tomorrow's view broken header handling

note : this is getting out of hands, we need to find a better way to handle sections and so on

Summary by CodeRabbit

  • New Features
    • Added an Upcoming view to the sidebar with a dedicated icon and selection highlighting.
    • Updated navigation: Today → Tomorrow → Upcoming, with mouse and keyboard support.
    • Upcoming tasks are grouped into sections (Overdue, Today, Tomorrow, Day After Tomorrow, and future dates) with clear headers.
    • Displayed message when no upcoming tasks are scheduled.
    • Disabled delete/edit shortcuts in Upcoming with explanatory notifications.
    • Faster loading of upcoming tasks via local data access for a more responsive experience.

while here, fixed the tomorrow's view broken header handling

note : this is getting out of hands, we need to find a better way to
handle sections and so on
@coderabbitai

coderabbitai Bot commented Sep 12, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds an Upcoming view across storage, sync, UI core, sidebar, and task list. Introduces IconService::upcoming(), storage/sync APIs to retrieve upcoming tasks, a new SidebarSelection::Upcoming variant, sidebar navigation/rendering for Upcoming, and task list rendering logic for grouped upcoming sections. Minor today query changes to SELECT *.

Changes

Cohort / File(s) Summary
Icons
src/icons.rs
Adds IconService::upcoming() returning a theme-specific upcoming icon; annotated with #[must_use].
Storage and Sync
src/storage.rs, src/sync.rs
Storage: get_tasks_for_today now uses SELECT *; adds get_tasks_for_upcoming() to fetch and assemble upcoming TaskDisplays (up to 3 months). Sync: adds get_tasks_for_upcoming() delegating to storage.
UI core enums and actions
src/ui/core/actions.rs, src/ui/core/task_manager.rs, src/ui/app_component.rs
Adds SidebarSelection::Upcoming. Task manager loads upcoming tasks via sync path. App component integrates Upcoming into selection descriptions, navigation handling, and disallows edit/delete.
Sidebar component
src/ui/components/sidebar_component.rs
Adds Upcoming item with icons.upcoming(). Updates navigation order, index mapping, rendering, and mouse handling to include Upcoming.
Task list component
src/ui/components/task_list_component.rs
Adds Upcoming view rendering: groups overdue and future tasks by date with headers; new index calculations for Tomorrow and Upcoming; integrates Upcoming branch in rendering flow.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

enhancement

Pre-merge checks (3 passed)

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "Feat: add Upcoming view" is concise, a single-sentence summary that directly reflects the primary change in the PR (adding an Upcoming view) and matches the PR objectives and diffs, so it effectively communicates the main intent to reviewers.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/upcoming_view

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/ui/components/task_list_component.rs (1)

617-650: Wrong empty-state message for Upcoming

Shows “No tasks in this project” when viewing Upcoming. Use an Upcoming-specific message.

-            let empty_message = if self.projects.is_empty() {
+            let empty_message = if self.projects.is_empty() {
                 "No projects available. Press 'r' to sync or 'A' to create a project."
             } else if matches!(self.sidebar_selection, SidebarSelection::Today) {
                 "No tasks due today. Press 'a' to create a task or 'r' to sync."
             } else if matches!(self.sidebar_selection, SidebarSelection::Tomorrow) {
                 "No tasks due tomorrow. Press 'a' to create a task or 'r' to sync."
+            } else if matches!(self.sidebar_selection, SidebarSelection::Upcoming) {
+                "No upcoming tasks scheduled. Press 'a' to create a task or 'r' to sync."
             } else {
                 "No tasks in this project. Press 'a' to create a task."
             };
src/ui/components/sidebar_component.rs (1)

104-117: Fix wrap-around: Previous from Today should go to Upcoming (not Tomorrow) when no labels/projects

With Upcoming inserted between Tomorrow and Labels, pressing “previous” on Today should wrap to Upcoming when there are no labels or projects. The current behavior creates a Today ↔ Tomorrow loop and skips Upcoming.

Apply:

             SidebarSelection::Today => {
                 if !self.projects.is_empty() {
                     let sorted_projects = self.get_sorted_projects();
                     if let Some((original_index, _)) = sorted_projects.last() {
                         SidebarSelection::Project(*original_index)
                     } else {
-                        SidebarSelection::Tomorrow
+                        SidebarSelection::Upcoming
                     }
                 } else if !self.labels.is_empty() {
                     SidebarSelection::Label(self.labels.len() - 1)
                 } else {
-                    SidebarSelection::Tomorrow
+                    SidebarSelection::Upcoming
                 }
             }
🧹 Nitpick comments (6)
src/storage.rs (3)

861-874: Prefer explicit column list over SELECT * for stability/perf

Avoids fetching unused columns and reduces fragility if schema changes.

-            SELECT *
-            FROM tasks
+            SELECT id, content, project_id, section_id, is_completed, is_deleted, priority,
+                   due_date, due_datetime, is_recurring, deadline, duration, labels, description
+            FROM tasks

918-920: Avoid N+1 label lookups by batching once per list

Currently we issue one SELECT per task. Batch once to cut DB round-trips.

Apply to both today and upcoming flows:

-        // Update label colors for all tasks
-        for task in &mut tasks {
-            self.update_task_labels(task).await?;
-        }
+        // Update label colors for all tasks in one go
+        self.update_labels_for_tasks(&mut tasks).await?;

Add helper (outside this hunk):

pub async fn update_labels_for_tasks(&self, tasks: &mut [TaskDisplay]) -> Result<()> {
    use std::collections::{HashMap, HashSet};
    let names: HashSet<String> = tasks.iter()
        .flat_map(|t| t.labels.iter().map(|l| l.name.clone()))
        .collect();
    if names.is_empty() { return Ok(()); }
    let stored = self.get_labels_by_ids(&names.into_iter().collect::<Vec<_>>()).await?;
    let map: HashMap<_, _> = stored.into_iter().map(|l| (l.name, l.color)).collect();
    for t in tasks {
        for ld in &mut t.labels {
            if let Some(c) = map.get(&ld.name) { ld.color = c.clone(); }
        }
    }
    Ok(())
}

Also applies to: 1054-1056


238-329: Add indexes to keep Upcoming/Today queries fast

Filters/sorts hit is_completed, is_deleted, due_date, priority, order_index. Add composite indexes.

         // Create tasks table
         sqlx::query(
             r"
             CREATE TABLE IF NOT EXISTS tasks (
               ...
             )
             "
         )
         .execute(&self.pool)
         .await?;
+
+        // Helpful indexes for date-based views
+        sqlx::query(
+            r"
+            CREATE INDEX IF NOT EXISTS idx_tasks_due_flags
+              ON tasks(is_completed, is_deleted, due_date);
+            CREATE INDEX IF NOT EXISTS idx_tasks_sort
+              ON tasks(due_date, priority DESC, order_index ASC);
+            "
+        ).execute(&self.pool).await?;
src/icons.rs (1)

293-300: LGTM: upcoming() theming helper

Matches existing pattern. Suggest adding tests for it alongside today/tomorrow.

 // tests/icons_test.rs
@@
 fn test_today_tomorrow_icons() {
@@
 }
+
+#[test]
+fn test_upcoming_icons() {
+    let emoji = IconService::new(IconTheme::Emoji);
+    assert_eq!(emoji.upcoming(), "📊");
+    let unicode = IconService::new(IconTheme::Unicode);
+    assert_eq!(unicode.upcoming(), "◎");
+    let ascii = IconService::new(IconTheme::Ascii);
+    assert_eq!(ascii.upcoming(), ">");
+}
src/ui/components/task_list_component.rs (1)

224-299: Use IconService instead of hard-coded emojis in headers

Keeps theming consistent and avoids mixed icon sources.

-            let date_header = if due_date == today {
-                "📅 Today".to_string()
-            } else if due_date == today + chrono::Duration::days(1) {
-                "📅 Tomorrow".to_string()
-            } else if due_date == today + chrono::Duration::days(2) {
-                "📊 Day After Tomorrow".to_string()
-            } else {
-                let weekday = due_date.format("%A").to_string();
-                let formatted_date = due_date.format("%b %d").to_string();
-                format!("📊 {} - {}", weekday, formatted_date)
-            };
+            let date_header = if due_date == today {
+                format!("{} {}", self.icons.today(), "Today")
+            } else if due_date == today + chrono::Duration::days(1) {
+                format!("{} {}", self.icons.tomorrow(), "Tomorrow")
+            } else if due_date == today + chrono::Duration::days(2) {
+                format!("{} {}", self.icons.upcoming(), "Day After Tomorrow")
+            } else {
+                let weekday = due_date.format("%A").to_string();
+                let formatted_date = due_date.format("%b %d").to_string();
+                format!("{} {} - {}", self.icons.upcoming(), weekday, formatted_date)
+            };
src/ui/components/sidebar_component.rs (1)

330-344: Render for Upcoming is correct; consider DRY-ing repeated styling code

Today/Tomorrow/Upcoming share identical selected/unselected style branches. A tiny helper would reduce duplication.

Example helper (outside the changed range):

fn nav_item(&self, selected: bool, icon: &str, label: &str) -> ListItem {
    let style = if selected {
        Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(Color::White)
    };
    ListItem::new(Line::from(vec![
        Span::styled(icon.to_string(), style),
        Span::styled(label.to_string(), style),
    ]))
}

Then here:

- all_items.push(ListItem::new(Line::from(vec![
-     Span::styled(self.icons.upcoming().to_string(), upcoming_style),
-     Span::styled("Upcoming".to_string(), upcoming_style),
- ])));
+ all_items.push(self.nav_item(is_upcoming_selected, self.icons.upcoming(), "Upcoming"));
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4874932 and a55bea3.

📒 Files selected for processing (8)
  • src/icons.rs (1 hunks)
  • src/storage.rs (2 hunks)
  • src/sync.rs (1 hunks)
  • src/ui/app_component.rs (4 hunks)
  • src/ui/components/sidebar_component.rs (5 hunks)
  • src/ui/components/task_list_component.rs (4 hunks)
  • src/ui/core/actions.rs (1 hunks)
  • src/ui/core/task_manager.rs (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
src/icons.rs (1)
tests/icons_test.rs (1)
  • test_today_tomorrow_icons (43-55)
src/sync.rs (1)
src/storage.rs (1)
  • get_tasks_for_upcoming (994-1059)
src/storage.rs (1)
src/sync.rs (1)
  • get_tasks_for_upcoming (123-126)
src/ui/components/task_list_component.rs (2)
src/ui/components/sidebar_component.rs (2)
  • new (29-39)
  • default (23-25)
src/todoist.rs (3)
  • from (50-59)
  • from (63-70)
  • from (74-109)
src/ui/components/sidebar_component.rs (1)
src/icons.rs (5)
  • default (20-22)
  • default (80-82)
  • new (88-90)
  • icons (114-120)
  • upcoming (294-300)
🔇 Additional comments (11)
src/ui/core/actions.rs (1)

9-11: LGTM: Upcoming selection added

Enum extension is clean and preserves default.

src/sync.rs (1)

122-127: LGTM: storage passthrough for Upcoming

Consistent with Today/Tomorrow methods.

src/ui/core/task_manager.rs (1)

204-207: LGTM: Upcoming data-load branch

Parallels existing flows and uses unwrap_or_default defensively.

src/ui/app_component.rs (1)

231-235: LGTM: Guard rails for Upcoming and selection logging

Consistent UX with Today/Tomorrow special views.

Also applies to: 283-287, 384-385

src/ui/components/task_list_component.rs (3)

87-89: LGTM: Rendered-index routing includes Upcoming

Keeps selection math consistent.


115-118: LGTM: Upcoming short-circuit in item creation

Prevents fallthrough into generic rendering.


711-782: LGTM: Rendered-index math for Tomorrow/Upcoming

Matches section/blank-line structure of the renderers.

src/ui/components/sidebar_component.rs (4)

49-63: Next selection flow from Tomorrow → Upcoming looks correct

The transition to Upcoming and the fallbacks into Labels/Projects/Today read well and match the new index order.


119-125: Good: Upcoming → Tomorrow and Label(0) → Upcoming

These preserve a consistent backward navigation order with the new section inserted.


142-143: Good: From first Project back to Upcoming when no labels

This completes the cyclic nav path when labels are absent.


225-228: Index mapping correct; no stale “2-based” offsets found

index 2 → Upcoming and labels begin at index 3 in src/ui/components/sidebar_component.rs (index_to_selection, lines 217–241). Repo-wide search returned no other index==2 or index±2 matches.

Comment thread src/storage.rs
Comment on lines +993 to +1059
/// Get tasks for upcoming from local storage (overdue + next 3 months)
pub async fn get_tasks_for_upcoming(&self) -> Result<Vec<TaskDisplay>> {
let rows = sqlx::query(
r"
SELECT *
FROM tasks
WHERE is_completed = false
AND is_deleted = false
AND due_date IS NOT NULL
AND due_date <= date('now', '+3 months')
ORDER BY
CASE
WHEN due_date < date('now') THEN 0 -- Overdue tasks first
WHEN due_date = date('now') THEN 1 -- Today's tasks second
ELSE 2 -- Future tasks third
END,
due_date ASC, -- Then chronological order
priority DESC, -- Then priority (high to low)
order_index ASC -- Finally by user's manual order
",
)
.fetch_all(&self.pool)
.await?;

let mut tasks = rows
.into_iter()
.map(|row| {
// Parse labels from JSON string
let label_names: Vec<String> =
serde_json::from_str(&row.get::<String, _>("labels")).unwrap_or_default();

// Convert label names to LabelDisplay objects (colors will be filled in later)
let labels = label_names
.into_iter()
.map(|name| crate::todoist::LabelDisplay {
id: name.clone(), // Use name as ID for now
name,
color: "blue".to_string(), // Default color, will be updated from storage
})
.collect();

TaskDisplay {
id: row.get("id"),
content: row.get("content"),
project_id: row.get("project_id"),
section_id: row.get("section_id"),
is_completed: row.get("is_completed"),
is_deleted: row.get("is_deleted"),
priority: row.get("priority"),
due: row.get("due_date"),
due_datetime: row.get("due_datetime"),
is_recurring: row.get("is_recurring"),
deadline: row.get("deadline"),
duration: row.get("duration"),
labels,
description: row.get("description"),
}
})
.collect::<Vec<TaskDisplay>>();

// Update label colors for all tasks
for task in &mut tasks {
self.update_task_labels(task).await?;
}

Ok(tasks)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Label colors never update: querying by ID but passing names

update_task_labels extracts label names, but get_labels_by_ids queries WHERE id IN (...). Result: empty hits and labels stay "blue".

Minimal fix: make the helper query by name.

-    /// Get labels by their IDs
-    pub async fn get_labels_by_ids(&self, label_ids: &[String]) -> Result<Vec<LocalLabel>> {
-        if label_ids.is_empty() {
+    /// Get labels by their names (matches TaskDisplay.label.name)
+    pub async fn get_labels_by_ids(&self, label_names: &[String]) -> Result<Vec<LocalLabel>> {
+        if label_names.is_empty() {
             return Ok(Vec::new());
         }
-        let placeholders = label_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
-        let query = format!("SELECT * FROM labels WHERE id IN ({placeholders}) ORDER BY order_index");
+        let placeholders = label_names.iter().map(|_| "?").collect::<Vec<_>>().join(",");
+        let query = format!("SELECT * FROM labels WHERE name IN ({placeholders}) ORDER BY order_index");
 
         let mut query_builder = sqlx::query_as::<_, LocalLabel>(&query);
-        for id in label_ids {
-            query_builder = query_builder.bind(id);
+        for name in label_names {
+            query_builder = query_builder.bind(name);
         }

Optionally rename method to get_labels_by_names for clarity (can update the single caller).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Get tasks for upcoming from local storage (overdue + next 3 months)
pub async fn get_tasks_for_upcoming(&self) -> Result<Vec<TaskDisplay>> {
let rows = sqlx::query(
r"
SELECT *
FROM tasks
WHERE is_completed = false
AND is_deleted = false
AND due_date IS NOT NULL
AND due_date <= date('now', '+3 months')
ORDER BY
CASE
WHEN due_date < date('now') THEN 0 -- Overdue tasks first
WHEN due_date = date('now') THEN 1 -- Today's tasks second
ELSE 2 -- Future tasks third
END,
due_date ASC, -- Then chronological order
priority DESC, -- Then priority (high to low)
order_index ASC -- Finally by user's manual order
",
)
.fetch_all(&self.pool)
.await?;
let mut tasks = rows
.into_iter()
.map(|row| {
// Parse labels from JSON string
let label_names: Vec<String> =
serde_json::from_str(&row.get::<String, _>("labels")).unwrap_or_default();
// Convert label names to LabelDisplay objects (colors will be filled in later)
let labels = label_names
.into_iter()
.map(|name| crate::todoist::LabelDisplay {
id: name.clone(), // Use name as ID for now
name,
color: "blue".to_string(), // Default color, will be updated from storage
})
.collect();
TaskDisplay {
id: row.get("id"),
content: row.get("content"),
project_id: row.get("project_id"),
section_id: row.get("section_id"),
is_completed: row.get("is_completed"),
is_deleted: row.get("is_deleted"),
priority: row.get("priority"),
due: row.get("due_date"),
due_datetime: row.get("due_datetime"),
is_recurring: row.get("is_recurring"),
deadline: row.get("deadline"),
duration: row.get("duration"),
labels,
description: row.get("description"),
}
})
.collect::<Vec<TaskDisplay>>();
// Update label colors for all tasks
for task in &mut tasks {
self.update_task_labels(task).await?;
}
Ok(tasks)
}
/// Get labels by their names (matches TaskDisplay.label.name)
pub async fn get_labels_by_ids(&self, label_names: &[String]) -> Result<Vec<LocalLabel>> {
if label_names.is_empty() {
return Ok(Vec::new());
}
let placeholders = label_names.iter().map(|_| "?").collect::<Vec<_>>().join(",");
let query = format!("SELECT * FROM labels WHERE name IN ({}) ORDER BY order_index", placeholders);
let mut query_builder = sqlx::query_as::<_, LocalLabel>(&query);
for name in label_names {
query_builder = query_builder.bind(name);
}
let rows = query_builder.fetch_all(&self.pool).await?;
Ok(rows)
}
🤖 Prompt for AI Agents
In src/storage.rs around lines 993-1059, the label-color update fails because
update_task_labels extracts label names but calls a helper that queries labels
by id; change the lookup to match by name instead of id. Implement or rename the
helper to get_labels_by_names (or modify its SQL) so it executes WHERE name IN
(...) and return LabelDisplay rows by matching on the name field; update the
single caller in this file (update_task_labels) to call the new/updated function
and pass the Vec<String> of names so stored colors replace the default "blue".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant