Skip to content

Refactor/global architecture - #22

Merged
romaintb merged 12 commits into
mainfrom
refactor/global_architecture
Sep 6, 2025
Merged

Refactor/global architecture#22
romaintb merged 12 commits into
mainfrom
refactor/global_architecture

Conversation

@romaintb

@romaintb romaintb commented Sep 5, 2025

Copy link
Copy Markdown
Owner

Trying to use the recommended architecture for ratatui apps

Summary by CodeRabbit

  • New Features

    • Redesigned component-based TUI: sidebar, task list, unified dialog system and new renderer loop.
    • CLI help (-h/--help) and clearer guidance when API token is missing.
    • Toggle task completion and immediate local refresh after creating/updating tasks/projects.
  • Improvements

    • Priority badges now show P1–P4; label badges no longer include extra padding.
    • Inbox projects exposed; improved background sync/init with timeout behavior.
    • Tab pane-switching shortcut removed.
  • Documentation

    • README and PRD refreshed.
  • Chores

    • Version bumped to 0.2.0.

@romaintb romaintb self-assigned this Sep 5, 2025
@romaintb romaintb added the enhancement New feature or request label Sep 5, 2025
@coderabbitai

coderabbitai Bot commented Sep 5, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Version bump plus a major UI rewrite: legacy TUI replaced by a component-based UI (AppComponent, core actions/traits, TaskManager, dialog/sidebar/task-list components, new_renderer). Storage and sync gained single-record write/update APIs and local persistence; main now requires TODOIST_API_TOKEN and starts the new renderer.

Changes

Cohort / File(s) Summary
Manifest
Cargo.toml
Package version bumped 0.1.0 → 0.2.0.
Docs
README.md, docs/PRD.md
Project structure and UI architecture rewritten to a component-based layout; wording and shortcuts updated.
Entrypoint
src/main.rs
Add CLI help, require TODOIST_API_TOKEN, timeout SyncService::new, and call ui::run_new_app(sync_service).
Data model & storage
src/todoist.rs, src/storage.rs
Add ProjectDisplay.is_inbox_project; add store_single_project, store_single_task, update_task_due_date, clear_all_data; map inbox flag in queries.
Sync service
src/sync.rs
Persist created/updated entities to local storage, add toggle_task async method, update due-date local sync and logging.
Icons & small utils
src/icons.rs, src/utils/*, src/debug_logger.rs
Add IconService::tomorrow(); remove some test blocks; minor newline/test adjustments; add debug_logger.rs.
UI public surface
src/ui/mod.rs, src/ui/*
Rename/re-export UI modules (appapp_component, eventscore, renderernew_renderer) and replace run_app with run_new_app.
UI core framework (new)
src/ui/core/...
Add actions (Action, DialogType, SidebarSelection), component trait, context (AppContext), event_handler (EventType), and task_manager (TaskManager, TaskResult).
New renderer & loop
src/ui/new_renderer.rs
Add run_new_app(sync_service) async runtime, AppStatus, event/tick integration, and AppComponent status/request_render helpers.
App component (new)
src/ui/app_component.rs
Add AppState and AppComponent orchestrating Sidebar/TaskList/Dialog components, SyncService, TaskManager, background actions, and event handling.
New UI components
src/ui/components/*
Add dialog_component.rs, sidebar_component.rs, task_list_component.rs; update badge.rs behavior and mod.rs exports.
Removed legacy UI modules
src/ui/app.rs, src/ui/renderer.rs, src/ui/events.rs, src/ui/components/dialogs/*, src/ui/components/{projects_list.rs,tasks_list.rs,help_panel.rs,status_bar.rs}
Delete legacy App state, renderer loop, event handlers, many dialog modules, sidebar/tasks/help/status modules and re-exports.
Tests added/changed
tests/*
Add tests for date utils, icons, todoist conversions, and EventHandler; remove some in-file test blocks replaced by external tests.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/global_architecture

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.

@romaintb
romaintb force-pushed the refactor/global_architecture branch from f794581 to 526bcdb Compare September 5, 2025 17:05
@romaintb
romaintb force-pushed the refactor/global_architecture branch from 526bcdb to b8b3f3e Compare September 5, 2025 17:15
@romaintb
romaintb marked this pull request as ready for review September 5, 2025 19:31

@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: 12

Caution

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

⚠️ Outside diff range comments (16)
src/ui/components/badge.rs (2)

99-105: Fix unreadable “silver” labels and spacing regression.

  • White fg on white/super-light bg makes label text invisible (e.g., "silver" -> White).
  • Removing padding around label text breaks visual separation with adjacent badges.

Apply contrast-aware fg and restore padding:

-    let style = Style::default()
-        .bg(bg_color)
-        .fg(Color::White)
-        .add_modifier(Modifier::BOLD);
-
-    Span::styled(name.to_string(), style)
+    // Choose contrasting fg, keep padded text
+    let mut style = Style::default().bg(bg_color).add_modifier(Modifier::BOLD);
+    style = match bg_color {
+        // Light backgrounds → black text
+        Color::White
+        | Color::Yellow
+        | Color::LightYellow
+        | Color::LightCyan
+        | Color::Cyan => style.fg(Color::Black),
+        // Heuristic for custom RGBs (orange, pink, brown, etc.)
+        Color::Rgb(r, g, b)
+            if (r as u32 * 299 + g as u32 * 587 + b as u32 * 114) >= 128_000 =>
+                style.fg(Color::Black),
+        _ => style.fg(Color::White),
+    };
+    Span::styled(format!(" {name} "), style)

Note: This also aligns spacing with other badges (create_badge adds padding), avoiding run-together spans like “(30m)label”.


90-95: Map “silver” to gray, not white, to preserve contrast.

Current mapping yields white bg; with prior fg=White it was invisible. Even with contrast fix, pure white is harsh; use a light gray.

-        "silver" => Color::White, // Changed from LightGray which doesn't exist
+        // Silver → light gray for better readability across terminals
+        "silver" => Color::Rgb(192, 192, 192),
README.md (2)

41-67: Implement or remove ‘i’ icon-theme shortcut

README lists i for cycling icon themes, but no KeyCode::Char('i') handler exists in src/ui—add the handler or remove the shortcut from the docs.


120-132: Synchronize README dependency versions with Cargo.toml

  • ratatui: bump from 0.24 → 0.29
  • crossterm: bump from 0.27 → 0.29
  • sqlx: bump from 0.7 → 0.8
  • serde: add version 1.0
  • todoist-api: replace 0.2.0 with the Git dependency spec in Cargo.toml
src/storage.rs (10)

551-577: Fix label resolution: query by label IDs and update both name and color

Currently you pass label names to get_labels_by_ids (which filters on id) and only patch colors. This leaves colors at default and names incorrect when tasks store label IDs (Todoist’s default). Resolve by collecting IDs from task_display.labels, fetching labels by ID, and updating both name and color.

 pub async fn update_task_labels(&self, task_display: &mut TaskDisplay) -> Result<()> {
     if task_display.labels.is_empty() {
         return Ok(());
     }

-    // Extract label names from the task
-    let label_names: Vec<String> = task_display.labels.iter().map(|l| l.name.clone()).collect();
-
-    // Get the actual label objects from storage
-    let stored_labels = self.get_labels_by_ids(&label_names).await?;
-
-    // Create a map of label names to colors
-    let mut label_color_map = std::collections::HashMap::new();
-    for label in stored_labels {
-        label_color_map.insert(label.name, label.color);
-    }
-
-    // Update the task labels with proper colors
-    for label_display in &mut task_display.labels {
-        if let Some(color) = label_color_map.get(&label_display.name) {
-            label_display.color = color.clone();
-        }
-    }
+    // Collect label IDs from the task
+    let label_ids: Vec<String> = task_display.labels.iter().map(|l| l.id.clone()).collect();
+    let stored_labels = self.get_labels_by_ids(&label_ids).await?;
+
+    // Map ID -> (name, color)
+    let mut labels_by_id = std::collections::HashMap::new();
+    for label in stored_labels {
+        labels_by_id.insert(label.id, (label.name, label.color));
+    }
+
+    // Update both name and color using ID match
+    for label_display in &mut task_display.labels {
+        if let Some((name, color)) = labels_by_id.get(&label_display.id) {
+            label_display.name = name.clone();
+            label_display.color = color.clone();
+        }
+    }
 
     Ok(())
 }

676-685: Exclude deleted tasks in project query

Deleted tasks appear in project views. Add a filter to hide them.

-            WHERE project_id = ? 
+            WHERE project_id = ?
+              AND is_deleted = false

704-719: NULL-safe description to prevent runtime decode errors

description is nullable in the schema. Reading it as String can panic. Read as Option<String> and default.

-                    description: row.get("description"),
+                    description: row
+                        .get::<Option<String>, _>("description")
+                        .unwrap_or_default(),

733-741: Exclude deleted tasks in “all tasks”

Align with other views and hide deleted tasks.

-            FROM tasks 
-            ORDER BY is_completed ASC, priority DESC, order_index ASC
+            FROM tasks
+            WHERE is_deleted = false
+            ORDER BY is_completed ASC, priority DESC, order_index ASC

760-775: NULL-safe description in “all tasks”

Same decoding issue as above.

-                    description: row.get("description"),
+                    description: row
+                        .get::<Option<String>, _>("description")
+                        .unwrap_or_default(),

833-848: NULL-safe description in “today/overdue”

Prevent decode errors on null descriptions.

-                    description: row.get("description"),
+                    description: row
+                        .get::<Option<String>, _>("description")
+                        .unwrap_or_default(),

901-916: NULL-safe description in “tomorrow”

Prevent decode errors on null descriptions.

-                    description: row.get("description"),
+                    description: row
+                        .get::<Option<String>, _>("description")
+                        .unwrap_or_default(),

579-596: Delete sections when deleting a project

Orphans remain in sections. Delete them (or add FK + cascade).

     pub async fn delete_project(&self, project_id: &str) -> Result<()> {
         let mut tx = self.pool.begin().await?;

         // Delete tasks first, then the project
         sqlx::query("DELETE FROM tasks WHERE project_id = ?")
             .bind(project_id)
             .execute(&mut *tx)
             .await?;

+        // Delete sections for this project
+        sqlx::query("DELETE FROM sections WHERE project_id = ?")
+            .bind(project_id)
+            .execute(&mut *tx)
+            .await?;
+
         sqlx::query("DELETE FROM projects WHERE id = ?")
             .bind(project_id)
             .execute(&mut *tx)
             .await?;

1014-1028: Clear all data should include sections and labels

The current reset skips sections and labels.

     pub async fn clear_all_data(&self) -> Result<()> {
         let mut tx = self.pool.begin().await?;

         sqlx::query("DELETE FROM tasks").execute(&mut *tx).await?;
+        sqlx::query("DELETE FROM sections").execute(&mut *tx).await?;
         sqlx::query("DELETE FROM projects")
             .execute(&mut *tx)
             .await?;
+        sqlx::query("DELETE FROM labels")
+            .execute(&mut *tx)
+            .await?;
         sqlx::query("DELETE FROM sync_metadata")
             .execute(&mut *tx)
             .await?;

1030-1046: Add migration for is_inbox_project

Older DBs may lack this column. Add it for forward compatibility.

     async fn run_migrations(&self) -> Result<()> {
         // Check if parent_id column exists in projects table
         let has_parent_id = sqlx::query_scalar::<_, Option<String>>(
             "SELECT name FROM pragma_table_info('projects') WHERE name = 'parent_id'",
         )
         .fetch_optional(&self.pool)
         .await?
         .is_some();

         if !has_parent_id {
             sqlx::query("ALTER TABLE projects ADD COLUMN parent_id TEXT")
                 .execute(&self.pool)
                 .await?;
         }
+
+        // Add is_inbox_project if missing
+        let has_inbox = sqlx::query_scalar::<_, Option<String>>(
+            "SELECT name FROM pragma_table_info('projects') WHERE name = 'is_inbox_project'",
+        )
+        .fetch_optional(&self.pool)
+        .await?
+        .is_some();
+        if !has_inbox {
+            sqlx::query("ALTER TABLE projects ADD COLUMN is_inbox_project BOOLEAN NOT NULL DEFAULT 0")
+                .execute(&self.pool)
+                .await?;
+        }

         Ok(())
     }
docs/PRD.md (2)

685-691: Docs mismatch with code: priority naming (P0–P3 vs [P1]–[P4]).

Help dialog lists [P1]..[P4] with P1 highest; PRD states P0–P3. Align terminology.

I can send a patch to standardize on one scheme across code and docs.


221-227: Module names reflect legacy architecture.

Sections still reference app.rs, renderer.rs, etc., while the code now uses app_component.rs, new_renderer.rs, and ui/core/*. Update to the new structure to reduce confusion.

I can draft updated sections mirroring the current module tree.

Also applies to: 425-451

🧹 Nitpick comments (41)
src/ui/core/event_handler.rs (1)

1-2: Import KeyEventKind; drop dead_code if intervals are now used.

-use crossterm::event::{poll, Event, KeyEvent};
+use crossterm::event::{poll, Event, KeyEvent, KeyEventKind};

If you adopt the change above, these allows can be removed:

-    #[allow(dead_code)]
     tick_interval: tokio::time::Interval,
-    #[allow(dead_code)]
     render_interval: tokio::time::Interval,
src/ui/mod.rs (3)

13-13: Provide a stable alias for the entrypoint; avoid the new_ prefix

Keep run_new_app exported, but also alias it to run_app so downstream callers can migrate without churn.

-pub use new_renderer::run_new_app;
+pub use new_renderer::run_new_app;
+// Prefer `run_app` going forward; keep `run_new_app` for transition.
+pub use new_renderer::run_new_app as run_app;

11-11: Offer a deprecated compatibility alias for App

If you previously exported App, consider a deprecated alias to ease migration.

 pub use app_component::AppComponent;
+#[deprecated(note = "Renamed to AppComponent")]
+pub use app_component::AppComponent as App;

5-9: Tighten the public surface of submodules

If external consumers don’t need to reach into these modules directly, make the modules private and re-export only the intended types from here to reduce API surface area.

README.md (1)

147-156: Add missing context.rs to the project structure tree

src/ui/core/context.rs exists but isn’t listed under Core in the README’s tree.

     ├── core/                  # Core architecture components
     │   ├── actions.rs         # Action system for component communication
     │   ├── component.rs       # Component trait and lifecycle
+    │   ├── context.rs         # Shared app context
     │   ├── event_handler.rs   # Event processing system
     │   └── task_manager.rs    # Background async task management
src/ui/core/mod.rs (2)

1-5: Limit module visibility; re-export symbols instead

Use private modules to avoid leaking internals; keep the public API via re-exports already provided.

-pub mod actions;
-pub mod component;
-pub mod context;
-pub mod event_handler;
-pub mod task_manager;
+mod actions;
+mod component;
+mod context;
+mod event_handler;
+mod task_manager;

7-12: Add a brief module doc and a prelude for ergonomics

Inline docs help discovery, and a prelude eases imports for downstream code.

+//! Core UI primitives: actions, components, events, context, and background tasks.
 // Re-exports for easier access
 pub use actions::{Action, DialogType, SidebarSelection};
 pub use component::Component;
 pub use context::AppContext;
 pub use event_handler::{EventHandler, EventType};
 pub use task_manager::{TaskId, TaskManager, TaskResult};
+
+/// Commonly used core items.
+pub mod prelude {
+    pub use super::actions::{Action, DialogType, SidebarSelection};
+    pub use super::component::Component;
+    pub use super::context::AppContext;
+    pub use super::event_handler::{EventHandler, EventType};
+    pub use super::task_manager::{TaskId, TaskManager, TaskResult};
+}
src/main.rs (3)

38-47: Exit with a non-zero code when the API token is missing

Returning Ok makes CI/shells treat this as success; exit with a failure code instead.

-        eprintln!("\n💡 Use --help for more options");
-        return Ok(());
+        eprintln!("\n💡 Use --help for more options");
+        std::process::exit(2);

52-62: Flatten timeout/result handling for clarity and add context

More idiomatic error propagation and a clearer timeout error.

-    match tokio::time::timeout(tokio::time::Duration::from_secs(10), sync::SyncService::new(api_token)).await {
-        Ok(Ok(sync_service)) => {
-            ui::run_new_app(sync_service).await?;
-        }
-        Ok(Err(e)) => {
-            return Err(e);
-        }
-        Err(_) => {
-            return Err(anyhow::anyhow!("Sync service creation timed out"));
-        }
-    }
+    let sync_service = tokio::time::timeout(
+        tokio::time::Duration::from_secs(10),
+        sync::SyncService::new(api_token),
+    )
+    .await
+    .map_err(|_| anyhow::anyhow!("Sync service creation timed out"))??;
+
+    ui::run_new_app(sync_service).await?;

14-17: Consider a CLI parser (clap) for options/help

Hand-rolled parsing is fine now, but clap will scale better as flags grow.

src/ui/core/context.rs (2)

8-15: Restrict field visibility to maintain invariants

Expose getters/setters as needed; this prevents accidental external mutation of core state.

-pub struct AppContext {
-    pub sync_service: SyncService,
-    pub projects: Vec<ProjectDisplay>,
-    pub labels: Vec<LabelDisplay>,
-    pub sections: Vec<SectionDisplay>,
-    pub icons: IconService,
-    pub debug_logger: DebugLogger,
-}
+#[derive(Debug)]
+pub struct AppContext {
+    pub(crate) sync_service: SyncService,
+    pub(crate) projects: Vec<ProjectDisplay>,
+    pub(crate) labels: Vec<LabelDisplay>,
+    pub(crate) sections: Vec<SectionDisplay>,
+    pub(crate) icons: IconService,
+    pub(crate) debug_logger: DebugLogger,
+}

17-27: Add minimal docs to clarify responsibilities

Doc comments make it easier for contributors to use/extend the context.

 impl AppContext {
-    pub fn new(sync_service: SyncService) -> Self {
+    /// Create an application context holding shared services and cached display data.
+    pub fn new(sync_service: SyncService) -> Self {
         Self {
             sync_service,
             projects: Vec::new(),
             labels: Vec::new(),
             sections: Vec::new(),
             icons: IconService::default(),
             debug_logger: DebugLogger::new(),
         }
     }
 }
src/sync.rs (2)

274-303: Due-date update: good logging + local write; prefer stronger typing for dates

Consider taking a typed date (e.g., chrono::NaiveDate) or a domain Date newtype and only stringify at API boundaries. This avoids invalid strings leaking into storage and simplifies validation.


317-350: Harden toggle_task for races, optimize lookup, and handle missing tasks

  • Race: local state may change after releasing the lock and before the API call; make the operation idempotent (e.g. attempt complete and fallback to reopen based on API response) or re-fetch the task state after the call to confirm.
  • Lookup: storage.get_all_tasks() is an O(n) scan; add a get_task_by_id(&self, task_id: &str) helper (or index) to avoid full-list iteration.
  • Not-found branch: currently assumes “complete” when the task isn’t found; distinguish between 404/deleted vs. genuinely incomplete for clearer UX.
src/ui/new_renderer.rs (5)

4-8: Terminal setup/restore: pair mouse capture enable/disable; consider panic-safe cleanup

Enable mouse capture on entry (you already disable it on exit). Optionally add a guard to restore terminal state on panic.

- use crossterm::{
-     event::DisableMouseCapture,
+ use crossterm::{
+     event::{EnableMouseCapture, DisableMouseCapture},
      execute,
      terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
  };
@@
-    execute!(stdout, EnterAlternateScreen)?;
+    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
@@
-    disable_raw_mode()?;
-    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
+    disable_raw_mode()?;
+    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;

If desired, wrap setup in a small RAII guard (e.g., scopeguard) to guarantee restore on panic.

Also applies to: 21-21, 44-48


32-42: Remove or wire unused cleanup/render intervals

They’re created and passed but unused. Either drive logic with them or drop for simplicity.

- use tokio::time::{interval, Duration};
+ // use tokio::time::Duration;
@@
-    // Create intervals for periodic tasks
-    let mut cleanup_interval = interval(Duration::from_secs(5)); // Clean up finished tasks every 5 seconds
-    let mut render_interval = interval(Duration::from_millis(16)); // ~60 FPS rendering
-    let result = run_app_loop(
-        &mut terminal,
-        &mut app,
-        &mut event_handler,
-        &mut cleanup_interval,
-        &mut render_interval,
-    )
+    let result = run_app_loop(
+        &mut terminal,
+        &mut app,
+        &mut event_handler,
+    )
     .await;
@@
-async fn run_app_loop<B: Backend>(
-    terminal: &mut Terminal<B>,
-    app: &mut AppComponent,
-    event_handler: &mut EventHandler,
-    _cleanup_interval: &mut tokio::time::Interval,
-    _render_interval: &mut tokio::time::Interval,
-) -> anyhow::Result<()> {
+async fn run_app_loop<B: Backend>(
+    terminal: &mut Terminal<B>,
+    app: &mut AppComponent,
+    event_handler: &mut EventHandler,
+) -> anyhow::Result<()> {

Also applies to: 52-58, 14-15


113-121: AppStatus shape: align time type with SyncService/chrono or reuse SyncStats

Consider chrono::DateTime for last_sync (or expose SyncStats) to avoid mixing SystemTime with Chrono elsewhere.


136-140: request_render is a no-op

Add a dirty flag in AppComponent that the loop checks, or post an internal Action::RequestRender to wake the loop.


146-166: Tests: good event timing check; add a quit-path smoke test

A minimal test where EventHandler yields a Quit action would exercise run_app_loop’s shutdown path. I can draft a mock EventHandler if helpful.

src/ui/core/component.rs (2)

10-16: Unify event type across UI

Components using crossterm::Event while the app routes EventType increases coupling to the backend. Consider handling your internal EventType here to keep crossterm localized.

- use crossterm::event::{Event, KeyEvent};
+ use crossterm::event::KeyEvent;
+ use super::event_handler::EventType;
@@
-    fn handle_events(&mut self, event: Option<Event>) -> Action {
-        if let Some(Event::Key(key)) = event {
+    fn handle_events(&mut self, event: Option<EventType>) -> Action {
+        if let Some(EventType::Key(key)) = event {
             self.handle_key_events(key)
         } else {
             Action::None
         }
     }

25-26: Render signature relies on ratatui’s Frame alias

If you plan to support multiple backends or future ratatui changes, consider a generic method signature (e.g., render<B: ratatui::backend::Backend>(&mut self, f: &mut ratatui::Frame, rect: Rect)).

src/storage.rs (4)

345-367: Consider updating sync metadata on single-project upsert

Keep last_sync_projects consistent after immediate upserts.

     pub async fn store_single_project(&self, project: Project) -> Result<()> {
         let local_project: LocalProject = project.into();

         sqlx::query(
@@
         .execute(&self.pool)
         .await?;

-        Ok(())
+        self.update_sync_timestamp("projects").await
     }

442-472: Consider updating sync metadata on single-task upsert

Mirror batch behavior for tasks.

     pub async fn store_single_task(&self, task: Task) -> Result<()> {
         let local_task: LocalTask = task.into();
@@
         .execute(&self.pool)
         .await?;

-        Ok(())
+        self.update_sync_timestamp("tasks").await
     }

217-307: Add indexes for hot paths

To keep UI snappy, index frequent predicates: (project_id), (is_deleted, is_completed), (due_date), and label lookups.

Example:

CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id);
CREATE INDEX IF NOT EXISTS idx_tasks_deleted_completed ON tasks(is_deleted, is_completed);
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
CREATE INDEX IF NOT EXISTS idx_labels_order ON labels(order_index);

520-547: Prefer compile-time checked queries

sqlx::query_as! (or query!) would catch column/type mismatches (e.g., nullable description) at build time.

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

201-209: Compute real tree depth for indentation

If deeper nesting appears, show correct indentation.

-    /// Since Todoist only has parent/child (no deeper nesting), depth is either 0 or 1
-    fn calculate_tree_depth(&self, project: &ProjectDisplay) -> usize {
-        if project.parent_id.is_some() {
-            1
-        } else {
-            0
-        }
-    }
+    fn calculate_tree_depth(&self, project: &ProjectDisplay) -> usize {
+        let mut depth = 0usize;
+        let mut current = project;
+        while let Some(parent_id) = &current.parent_id {
+            if let Some(parent) = self.projects.iter().find(|p| p.id == *parent_id) {
+                depth += 1;
+                current = parent;
+            } else {
+                break;
+            }
+        }
+        depth
+    }

215-223: Support lowercase vim keys ‘j’/‘k’

Common TUI convention.

-            KeyCode::Char('J') | KeyCode::Down => {
+            KeyCode::Char('j') | KeyCode::Char('J') | KeyCode::Down => {
@@
-            KeyCode::Char('K') | KeyCode::Up => {
+            KeyCode::Char('k') | KeyCode::Char('K') | KeyCode::Up => {

283-287: Unicode-safe truncation

Slicing strings by byte index can split UTF‑8 graphemes and panic. Truncate by graphemes or display width.

Example helper (outside this file):

fn truncate_graphemes(s: &str, max: usize) -> String {
    use unicode_segmentation::UnicodeSegmentation;
    let g = s.graphemes(true).collect::<Vec<_>>();
    if g.len() > max { format!("{}...", g[..max.saturating_sub(3)].concat()) } else { s.to_string() }
}

Then replace current slicing with the helper for labels and projects.

Also applies to: 318-324


14-19: Consider making fields private

Expose setters/APIs instead of public fields to keep invariants on selection/projects/labels/icons.

src/ui/components/dialog_component.rs (5)

67-113: Reject blank/whitespace-only submissions.

Use .trim().is_empty() across submit branches.

Apply this diff:

-                if !self.input_buffer.is_empty() {
+                if !self.input_buffer.trim().is_empty() {
@@
-                if !self.input_buffer.is_empty() {
+                if !self.input_buffer.trim().is_empty() {
@@
-                if !self.input_buffer.is_empty() {
+                if !self.input_buffer.trim().is_empty() {
@@
-                if !self.input_buffer.is_empty() {
+                if !self.input_buffer.trim().is_empty() {

Also applies to: 114-125, 126-136, 137-148


1059-1091: Avoid cloning DialogType in render (unnecessary allocs).

Borrow instead of cloning and pass string refs.

Apply this diff:

-    fn render(&mut self, f: &mut Frame, rect: Rect) {
-        if let Some(dialog_type) = self.dialog_type.clone() {
-            match dialog_type {
+    fn render(&mut self, f: &mut Frame, rect: Rect) {
+        if let Some(dialog_type) = self.dialog_type.as_ref() {
+            match dialog_type {
                 DialogType::TaskCreation { .. } => self.render_task_creation_dialog(f, rect),
                 DialogType::TaskEdit { .. } => self.render_task_edit_dialog(f, rect),
                 DialogType::ProjectCreation => {
                     self.render_project_creation_dialog(f, rect);
                 }
                 DialogType::ProjectEdit { .. } => {
                     self.render_project_edit_dialog(f, rect);
                 }
                 DialogType::LabelCreation => {
                     self.render_label_creation_dialog(f, rect);
                 }
                 DialogType::LabelEdit { .. } => {
                     self.render_label_edit_dialog(f, rect);
                 }
-                DialogType::DeleteConfirmation { item_type, .. } => {
-                    self.render_delete_confirmation_dialog(f, rect, &item_type);
+                DialogType::DeleteConfirmation { item_type, .. } => {
+                    self.render_delete_confirmation_dialog(f, rect, item_type);
                 }
-                DialogType::Info(message) => {
-                    self.render_info_dialog(f, rect, &message);
+                DialogType::Info(message) => {
+                    self.render_info_dialog(f, rect, message);
                 }
-                DialogType::Error(message) => {
-                    self.render_error_dialog(f, rect, &message);
+                DialogType::Error(message) => {
+                    self.render_error_dialog(f, rect, message);
                 }
                 DialogType::Help => {
                     self.render_help_dialog(f, rect);
                 }
                 DialogType::Logs => {
                     self.render_logs_dialog(f, rect);
                 }
             }
         }
     }

206-209: Avoid setting scroll_offset to usize::MAX.

This sentinel is brittle and leaks into state updates. Track a boolean “jump_to_bottom” or clamp using known content length at render time without mutating scrollbar_state here.

Happy to sketch a minimal change that defers clamping entirely to render paths.


746-753: Use IconService for emojis to respect ASCII/unicode themes.

Hardcoded emojis in titles/legends can degrade in non-Unicode terminals.

Example:

-                    .title("📖 Help - Press 'Esc', '?' or 'h' to close")
+                    .title(format!("{} Help - Press 'Esc', '?' or 'h' to close", self.icons.info()))
@@
-                    .title("🔍 Debug Logs - Press 'Esc', 'G' or 'q' to close")
+                    .title(format!("{} Debug Logs - Press 'Esc', 'G' or 'q' to close", self.icons.info()))
@@
-🔳          Pending task
-✅          Completed task
-❌          Deleted task
+{}          Pending task
+{}          Completed task
+{}          Deleted task

And expand IconService to expose suitable symbols.

Also applies to: 831-835, 686-691


995-1001: Minor UX: keep project selection valid when cycling with Tab after data refresh.

Combined with clamping in update_data, Tab cycling will remain safe even if the projects list changes.

No changes needed beyond the update_data clamp above.

Also applies to: 54-57

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

168-173: Avoid stringly-typed is_syncing().

Checking description.contains("sync") can false-match. Track a task kind flag or a dedicated is_sync boolean on BackgroundTask.

I can propose a minimal enum Kind { Sync, Op, DataLoad } field and update filters.


197-265: Data load swallows errors with unwrap_or_default; consider surfacing partial failures.

On failure to get tasks, you drop the error and continue silently. Emit an error action or log via DebugLogger to aid diagnostics.

I can wire an Action::ShowDialog(Error(...)) for the specific failing branch.

src/ui/core/actions.rs (2)

5-11: Consider using more specific index types for better type safety.

Using usize indices into vectors can lead to runtime panics if the indices are out of bounds. Consider using newtype wrappers or IDs that directly reference the entities.

 pub enum SidebarSelection {
     #[default]
     Today, // Today view (special view)
     Tomorrow,       // Tomorrow view (special view)
-    Label(usize),   // Index into labels vector
-    Project(usize), // Index into projects vector
+    Label(String),   // Label ID
+    Project(String), // Project ID
 }

This would eliminate the need for bounds checking and make the code more robust against data changes between selections.


88-89: Consider moving default_project_id to be a field at the DialogType level.

The default_project_id field appears only in TaskCreation, but based on the UI flow, other creation dialogs might benefit from having default context. Consider a more uniform approach.

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

741-742: Remove unused import.

The use std::collections::HashMap; import on line 741 appears to be redundant since HashMap is already imported at line 17.

-    use std::collections::HashMap;
-
src/ui/app_component.rs (2)

31-32: Remove obsolete comment about scrolling.

The comment "didnt we just got rid of custom scrolling ?" appears to be a leftover from development and should be removed or clarified.

     pub show_help: bool,
-    /// didnt we just got rid of custom scrolling ?
     pub help_scroll_offset: usize,

610-612: Error handling could be more robust.

The toggle task operation returns a Result but only formats success/error messages. Consider propagating more detailed error information.

Consider using anyhow's context to add more information:

 "Toggle task" => match sync_service.toggle_task(&task_info).await {
     Ok(()) => Ok(format!("✅ Task toggled: {}", task_info)),
-    Err(e) => Err(format!("❌ Failed to toggle task: {}", e)),
+    Err(e) => Err(format!("❌ Failed to toggle task {}: {}", task_info, e)),
 },
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a2abedf and 682507b.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (44)
  • Cargo.toml (1 hunks)
  • README.md (1 hunks)
  • docs/PRD.md (1 hunks)
  • src/main.rs (1 hunks)
  • src/storage.rs (6 hunks)
  • src/sync.rs (5 hunks)
  • src/todoist.rs (2 hunks)
  • src/ui/app.rs (0 hunks)
  • src/ui/app_component.rs (1 hunks)
  • src/ui/components/badge.rs (2 hunks)
  • src/ui/components/dialog_component.rs (1 hunks)
  • src/ui/components/dialogs/debug_dialog.rs (0 hunks)
  • src/ui/components/dialogs/delete_confirmation_dialog.rs (0 hunks)
  • src/ui/components/dialogs/error_dialog.rs (0 hunks)
  • src/ui/components/dialogs/info_dialog.rs (0 hunks)
  • src/ui/components/dialogs/label_creation_dialog.rs (0 hunks)
  • src/ui/components/dialogs/label_delete_confirmation_dialog.rs (0 hunks)
  • src/ui/components/dialogs/label_edit_dialog.rs (0 hunks)
  • src/ui/components/dialogs/mod.rs (0 hunks)
  • src/ui/components/dialogs/project_creation_dialog.rs (0 hunks)
  • src/ui/components/dialogs/project_delete_confirmation_dialog.rs (0 hunks)
  • src/ui/components/dialogs/project_edit_dialog.rs (0 hunks)
  • src/ui/components/dialogs/syncing_dialog.rs (0 hunks)
  • src/ui/components/dialogs/task_creation_dialog.rs (0 hunks)
  • src/ui/components/dialogs/task_edit_dialog.rs (0 hunks)
  • src/ui/components/help_panel.rs (0 hunks)
  • src/ui/components/mod.rs (1 hunks)
  • src/ui/components/projects_list.rs (0 hunks)
  • src/ui/components/sidebar_component.rs (1 hunks)
  • src/ui/components/status_bar.rs (0 hunks)
  • src/ui/components/task_list_component.rs (1 hunks)
  • src/ui/components/tasks_list.rs (0 hunks)
  • src/ui/core/actions.rs (1 hunks)
  • src/ui/core/component.rs (1 hunks)
  • src/ui/core/context.rs (1 hunks)
  • src/ui/core/event_handler.rs (1 hunks)
  • src/ui/core/mod.rs (1 hunks)
  • src/ui/core/task_manager.rs (1 hunks)
  • src/ui/events.rs (0 hunks)
  • src/ui/mod.rs (1 hunks)
  • src/ui/new_renderer.rs (1 hunks)
  • src/ui/renderer.rs (0 hunks)
  • src/utils/date.rs (1 hunks)
  • src/utils/mod.rs (1 hunks)
💤 Files with no reviewable changes (21)
  • src/ui/components/status_bar.rs
  • src/ui/components/dialogs/delete_confirmation_dialog.rs
  • src/ui/components/projects_list.rs
  • src/ui/components/dialogs/task_edit_dialog.rs
  • src/ui/components/dialogs/debug_dialog.rs
  • src/ui/renderer.rs
  • src/ui/components/dialogs/task_creation_dialog.rs
  • src/ui/components/dialogs/label_delete_confirmation_dialog.rs
  • src/ui/components/dialogs/label_creation_dialog.rs
  • src/ui/components/dialogs/project_creation_dialog.rs
  • src/ui/events.rs
  • src/ui/components/dialogs/label_edit_dialog.rs
  • src/ui/components/dialogs/project_delete_confirmation_dialog.rs
  • src/ui/components/dialogs/syncing_dialog.rs
  • src/ui/components/dialogs/error_dialog.rs
  • src/ui/components/dialogs/info_dialog.rs
  • src/ui/components/help_panel.rs
  • src/ui/components/dialogs/mod.rs
  • src/ui/app.rs
  • src/ui/components/tasks_list.rs
  • src/ui/components/dialogs/project_edit_dialog.rs
🧰 Additional context used
🧬 Code graph analysis (19)
src/ui/core/mod.rs (1)
src/lib.rs (1)
  • ui (6-6)
src/ui/components/badge.rs (1)
src/ui/components/task_list_component.rs (1)
  • create_badge (471-473)
src/ui/components/mod.rs (1)
src/ui/components/dialogs/mod.rs (5)
  • task_edit_dialog (15-15)
  • debug_dialog (3-3)
  • task_creation_dialog (14-14)
  • syncing_dialog (13-13)
  • project_edit_dialog (12-12)
src/ui/core/context.rs (7)
src/icons.rs (1)
  • icons (114-120)
src/sync.rs (2)
  • sync (353-373)
  • new (29-40)
src/ui/app_component.rs (1)
  • new (78-101)
src/ui/components/dialog_component.rs (2)
  • new (39-52)
  • default (33-35)
src/ui/components/sidebar_component.rs (2)
  • new (28-35)
  • default (22-24)
src/ui/components/task_list_component.rs (2)
  • new (81-92)
  • default (75-77)
src/ui/app.rs (3)
  • App (20-71)
  • App (79-1233)
  • new (82-138)
src/ui/components/dialog_component.rs (6)
src/icons.rs (3)
  • icons (114-120)
  • info (253-255)
  • warning (258-260)
src/ui/components/task_list_component.rs (5)
  • default (75-77)
  • new (81-92)
  • handle_key_events (525-605)
  • update (607-633)
  • render (635-678)
src/sync.rs (2)
  • new (29-40)
  • set_debug_logger (43-45)
src/ui/app_component.rs (4)
  • new (78-101)
  • handle_key_events (845-848)
  • update (850-857)
  • render (859-882)
src/ui/layout.rs (1)
  • centered_rect (67-85)
src/ui/core/component.rs (3)
  • handle_key_events (18-18)
  • update (20-23)
  • render (25-25)
src/ui/components/sidebar_component.rs (3)
src/icons.rs (3)
  • icons (114-120)
  • today (296-302)
  • label (287-293)
src/ui/components/projects_list.rs (2)
  • Sidebar (15-15)
  • Sidebar (17-209)
src/ui/app.rs (1)
  • SidebarSelection (12-17)
src/ui/core/actions.rs (2)
src/sync.rs (1)
  • sync (353-373)
src/ui/app.rs (3)
  • App (20-71)
  • App (79-1233)
  • SidebarSelection (12-17)
src/sync.rs (1)
src/ui/app.rs (3)
  • load_local_data (469-556)
  • toggle_selected_task (668-694)
  • create_task (941-989)
src/main.rs (4)
src/sync.rs (2)
  • sync (353-373)
  • new (29-40)
src/ui/app_component.rs (1)
  • new (78-101)
src/ui/new_renderer.rs (1)
  • run_new_app (17-50)
src/ui/renderer.rs (1)
  • run_app (26-67)
src/ui/new_renderer.rs (3)
src/sync.rs (2)
  • sync (353-373)
  • new (29-40)
src/ui/app_component.rs (1)
  • new (78-101)
src/ui/core/event_handler.rs (2)
  • new (13-19)
  • should_render (44-46)
src/ui/core/component.rs (4)
src/ui/app_component.rs (3)
  • handle_key_events (845-848)
  • update (850-857)
  • render (859-882)
src/ui/components/dialog_component.rs (3)
  • handle_key_events (857-1007)
  • update (1009-1057)
  • render (1059-1093)
src/ui/components/sidebar_component.rs (3)
  • handle_key_events (213-225)
  • update (227-236)
  • render (238-343)
src/ui/components/task_list_component.rs (3)
  • handle_key_events (525-605)
  • update (607-633)
  • render (635-678)
README.md (1)
src/ui/app.rs (2)
  • App (20-71)
  • App (79-1233)
src/ui/components/task_list_component.rs (5)
src/icons.rs (3)
  • icons (114-120)
  • label (287-293)
  • tasks_title (238-240)
src/ui/components/badge.rs (7)
  • to_style (23-57)
  • create_priority_badge (139-147)
  • create_task_badges (109-135)
  • create_badge (62-64)
  • create_bracket_badge (68-70)
  • create_paren_badge (74-76)
  • create_label_badge (80-105)
src/ui/components/dialog_component.rs (6)
  • default (33-35)
  • new (39-52)
  • update_data (54-57)
  • handle_key_events (857-1007)
  • update (1009-1057)
  • render (1059-1093)
src/ui/app_component.rs (5)
  • new (78-101)
  • update_data (37-48)
  • handle_key_events (845-848)
  • update (850-857)
  • render (859-882)
src/ui/components/tasks_list.rs (3)
  • create_task_item (250-341)
  • create_task_list_items (64-147)
  • create_today_task_items (150-217)
src/ui/core/event_handler.rs (2)
src/ui/renderer.rs (1)
  • run_ui (70-123)
src/ui/events.rs (2)
  • handle_task_creation_mode (198-223)
  • handle_debug_modal (518-552)
src/ui/core/task_manager.rs (2)
src/sync.rs (3)
  • sync (353-373)
  • new (29-40)
  • is_syncing (123-125)
src/ui/app_component.rs (3)
  • new (78-101)
  • spawn_task_operation (600-742)
  • is_syncing (113-115)
docs/PRD.md (4)
src/ui/app.rs (2)
  • App (20-71)
  • App (79-1233)
src/ui/components/dialogs/mod.rs (1)
  • syncing_dialog (13-13)
src/lib.rs (1)
  • ui (6-6)
src/ui/components/projects_list.rs (1)
  • Sidebar (15-15)
src/storage.rs (1)
src/sync.rs (1)
  • update_task_due_date (273-303)
src/ui/mod.rs (4)
src/ui/new_renderer.rs (1)
  • run_new_app (17-50)
src/ui/renderer.rs (1)
  • render_ui (126-192)
src/ui/app.rs (1)
  • App (20-71)
src/ui/layout.rs (1)
  • LayoutManager (6-6)
src/ui/app_component.rs (6)
src/sync.rs (3)
  • sync (353-373)
  • new (29-40)
  • is_syncing (123-125)
src/ui/components/dialog_component.rs (6)
  • update_data (54-57)
  • new (39-52)
  • default (33-35)
  • handle_key_events (857-1007)
  • update (1009-1057)
  • render (1059-1093)
src/ui/components/sidebar_component.rs (6)
  • update_data (37-40)
  • new (28-35)
  • default (22-24)
  • handle_key_events (213-225)
  • update (227-236)
  • render (238-343)
src/ui/components/task_list_component.rs (6)
  • update_data (94-108)
  • new (81-92)
  • default (75-77)
  • handle_key_events (525-605)
  • update (607-633)
  • render (635-678)
src/ui/core/task_manager.rs (4)
  • new (38-49)
  • default (280-283)
  • is_syncing (169-173)
  • spawn_task_operation (90-138)
src/utils/date.rs (2)
  • next_weekday (11-19)
  • format_ymd (6-8)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Security Audit
  • GitHub Check: Test (beta)
  • GitHub Check: Test (stable)
🔇 Additional comments (21)
src/ui/components/badge.rs (1)

141-146: Confirm change: wildcard now returns P4 badge instead of skipping

This makes create_priority_badge(…) always return Some, so unknown priorities will render a white “P4” badge rather than nothing.

  • If this is desired, change the signature to -> Span<'static> and drop the Option (remove wrapping Some/None).
  • If not, revert _ => Some(…) back to _ => None to restore “no badge” for unknown values.
src/utils/date.rs (1)

47-47: LGTM: trailing newline only; no functional changes.

src/utils/mod.rs (1)

1-1: LGTM: module export intact.

Cargo.toml (2)

3-3: Version bump to 0.2.0: looks good.


3-3: Pin git dependency and verify ratatui/crossterm compatibility

  • Use a fixed commit for todoist-api:
    todoist-api = { git = "https://github.com/romaintb/todoist-api", rev = "<PINNED_COMMIT_SHA>" }
  • Manually confirm you have a single crossterm version by running:
    cargo tree -p crossterm
src/ui/core/event_handler.rs (1)

21-36: Render and tick intervals wired; fixed sleep removed; KeyEventKind::Press filtering added.

src/sync.rs (2)

149-154: Immediate local persistence after project creation — LGTM

Storing the created project locally right after the API call will make the UI feel snappier.


180-185: Immediate local persistence after task creation — LGTM

Same positive effect for tasks; good call.

src/ui/new_renderer.rs (1)

123-133: get_status last_sync is always None

Plumb the last sync into AppComponent state or query a cached value so this field is meaningful.

src/storage.rs (1)

74-75: ProjectDisplay mapping: inbox flag propagation looks good

The new is_inbox_project is correctly forwarded.

src/ui/components/mod.rs (1)

5-13: LGTM: module layout and re-exports match the new component architecture

The module surface is clean and minimal.

src/ui/components/dialog_component.rs (1)

8-8: Ignore incorrect import suggestion

centered_rect is defined as an associated function on LayoutManager; use crate::ui::layout::LayoutManager and calls to LayoutManager::centered_rect(...) are valid.

Likely an incorrect or invalid review comment.

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

61-66: DataLoaded action carries display types directly.

The DataLoaded action variant properly uses display types from the todoist module, which is good for separation of concerns.

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

115-117: Potential index out of bounds when adjusting selected_index.

The code uses saturating_sub which is good, but the logic could be clearer by using min directly.

 if self.selected_index >= self.tasks.len() {
-    self.selected_index = self.tasks.len().saturating_sub(1);
+    self.selected_index = self.tasks.len().saturating_sub(1).min(self.tasks.len() - 1);
 }

Actually, the existing code is correct. The saturating_sub already handles the edge case properly.


559-574: Good project context handling for task creation.

The logic properly determines the default project based on the current sidebar selection, defaulting to inbox for non-project views.


194-195: No changes needed for date parsing

Todoist’s due.date field is returned in YYYY-MM-DD format, which aligns with chrono::NaiveDate::parse_from_str(due_date_str, "%Y-%m-%d").

src/ui/app_component.rs (5)

620-632: Good date string extraction pattern.

The code properly extracts task_id from the compound string format and handles the split correctly.


653-666: Date utility functions are properly used.

The code correctly uses the date utility functions for calculating next week and weekend dates.


738-739: Good error conversion pattern.

Converting string errors to anyhow errors with map_err is the right approach.


789-841: Well-structured event handling hierarchy.

The event handling properly prioritizes dialog > sidebar > task list > global keys, which is the correct order for a modal UI.


114-115: Consider using the sync service's is_syncing method.

Instead of tracking active_sync_task locally, you could directly use the sync service's is_syncing() method to reduce state duplication.

 pub fn is_syncing(&self) -> bool {
-    self.active_sync_task.is_some()
+    // Use tokio::task::block_in_place or make this async
+    // For now, keeping local state is actually better for performance
+    self.active_sync_task.is_some()
 }

Actually, keeping the local state is better to avoid blocking async calls in a sync context.

Comment thread src/todoist.rs
Comment on lines +12 to 13
pub is_inbox_project: bool,
}

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.

💡 Verification agent

🧩 Analysis chain

Good addition: propagate inbox flag; add a test and scan for struct literals.

  • Field is correctly mapped from API to display.
  • Please assert it in tests and ensure no direct ProjectDisplay { ... } literals are missing the new field.

Add to test_project_conversion:

assert_eq!(display.is_inbox_project, false);

Scan for potential breakages:

Also applies to: 57-58


🏁 Script executed:

#!/bin/bash
# Find all struct literal inits; confirm they set `is_inbox_project`
rg -nP --type=rust 'ProjectDisplay\s*\{' -C2

Length of output: 1403


Include is_inbox_project in all ProjectDisplay literals

  • In impl From<Project> for ProjectDisplay (src/todoist.rs), add
    is_inbox_project: project.is_inbox_project (or the correct flag) in the Self { … } block.
  • In impl From<LocalProject> for ProjectDisplay (src/storage.rs), add
    is_inbox_project: false in its Self { … }.
  • In the row-mapping in src/storage.rs (.map(|row| ProjectDisplay { … })), add
    is_inbox_project: row.get("is_inbox_project").
  • Update test_project_conversion to include
    assert_eq!(display.is_inbox_project, false);.
🤖 Prompt for AI Agents
In src/todoist.rs around lines 12-13 (the ProjectDisplay struct and its
From<Project> impl), src/storage.rs where impl From<LocalProject> for
ProjectDisplay is defined and where rows are mapped (.map(|row| ProjectDisplay {
… })), and the test file with test_project_conversion, add the missing
is_inbox_project field to every ProjectDisplay construction: in From<Project>
set is_inbox_project: project.is_inbox_project (or the correct source flag), in
From<LocalProject> set is_inbox_project: false, and in the row-mapping set
is_inbox_project: row.get("is_inbox_project"); then update the
test_project_conversion to assert_eq!(display.is_inbox_project, false); so all
literals and mappings include the new boolean field consistently.

Comment on lines +54 to +57
pub fn update_data(&mut self, projects: Vec<ProjectDisplay>, labels: Vec<LabelDisplay>) {
self.projects = projects;
self.labels = labels;
}

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.

🛠️ Refactor suggestion

Clamp selected_project_index when refreshing data to avoid OOB panics.

If projects shrink while a dialog is open, self.projects[self.selected_project_index] can panic.

Apply this diff:

 pub fn update_data(&mut self, projects: Vec<ProjectDisplay>, labels: Vec<LabelDisplay>) {
     self.projects = projects;
     self.labels = labels;
+    // Keep selection in-bounds
+    if !self.projects.is_empty() {
+        self.selected_project_index = self.selected_project_index.min(self.projects.len() - 1);
+    } else {
+        self.selected_project_index = 0;
+    }
 }
📝 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
pub fn update_data(&mut self, projects: Vec<ProjectDisplay>, labels: Vec<LabelDisplay>) {
self.projects = projects;
self.labels = labels;
}
pub fn update_data(&mut self, projects: Vec<ProjectDisplay>, labels: Vec<LabelDisplay>) {
self.projects = projects;
self.labels = labels;
// Keep selection in-bounds
if !self.projects.is_empty() {
self.selected_project_index = self.selected_project_index.min(self.projects.len() - 1);
} else {
self.selected_project_index = 0;
}
}
🤖 Prompt for AI Agents
In src/ui/components/dialog_component.rs around lines 54 to 57, when updating
self.projects the code doesn't clamp self.selected_project_index, which can lead
to an out-of-bounds panic if the projects list has shrunk; update the method to
reset or clamp selected_project_index after assigning self.projects — e.g., if
projects.is_empty() set selected_project_index to 0, otherwise set it to
min(self.selected_project_index, projects.len() - 1) (use saturating_sub(1) or
checked arithmetic to avoid underflow).

Comment on lines +963 to +994
KeyCode::Esc => Action::HideDialog,
KeyCode::Enter => self.handle_submit(),
KeyCode::Char(c) => {
self.input_buffer.insert(self.cursor_position, c);
self.cursor_position += 1;
Action::None
}
KeyCode::Backspace => {
if self.cursor_position > 0 {
self.input_buffer.remove(self.cursor_position - 1);
self.cursor_position -= 1;
}
Action::None
}
KeyCode::Delete => {
if self.cursor_position < self.input_buffer.len() {
self.input_buffer.remove(self.cursor_position);
}
Action::None
}
KeyCode::Left => {
if self.cursor_position > 0 {
self.cursor_position -= 1;
}
Action::None
}
KeyCode::Right => {
if self.cursor_position < self.input_buffer.len() {
self.cursor_position += 1;
}
Action::None
}

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

Fix Unicode cursor/editing: using byte indices as “char” count can panic/corrupt text.

String::len/insert/remove operate on bytes; the current per-1 increments break for multi-byte chars and can panic on removal. Track cursor as a byte offset and move/delete on char boundaries.

Apply this diff:

-                    KeyCode::Char(c) => {
-                        self.input_buffer.insert(self.cursor_position, c);
-                        self.cursor_position += 1;
-                        Action::None
-                    }
+                    KeyCode::Char(c) => {
+                        self.input_buffer.insert(self.cursor_position, c);
+                        self.cursor_position += c.len_utf8();
+                        Action::None
+                    }
@@
-                    KeyCode::Backspace => {
-                        if self.cursor_position > 0 {
-                            self.input_buffer.remove(self.cursor_position - 1);
-                            self.cursor_position -= 1;
-                        }
-                        Action::None
-                    }
+                    KeyCode::Backspace => {
+                        if self.cursor_position > 0 {
+                            if let Some(prev) = self.input_buffer[..self.cursor_position].chars().next_back() {
+                                let new_pos = self.cursor_position - prev.len_utf8();
+                                self.input_buffer.drain(new_pos..self.cursor_position);
+                                self.cursor_position = new_pos;
+                            } else {
+                                self.cursor_position = 0;
+                            }
+                        }
+                        Action::None
+                    }
@@
-                    KeyCode::Delete => {
-                        if self.cursor_position < self.input_buffer.len() {
-                            self.input_buffer.remove(self.cursor_position);
-                        }
-                        Action::None
-                    }
+                    KeyCode::Delete => {
+                        if self.cursor_position < self.input_buffer.len() {
+                            if let Some(next) = self.input_buffer[self.cursor_position..].chars().next() {
+                                let end = self.cursor_position + next.len_utf8();
+                                self.input_buffer.drain(self.cursor_position..end);
+                            }
+                        }
+                        Action::None
+                    }
@@
-                    KeyCode::Left => {
-                        if self.cursor_position > 0 {
-                            self.cursor_position -= 1;
-                        }
-                        Action::None
-                    }
+                    KeyCode::Left => {
+                        if self.cursor_position > 0 {
+                            if let Some(prev) = self.input_buffer[..self.cursor_position].chars().next_back() {
+                                self.cursor_position -= prev.len_utf8();
+                            } else {
+                                self.cursor_position = 0;
+                            }
+                        }
+                        Action::None
+                    }
@@
-                    KeyCode::Right => {
-                        if self.cursor_position < self.input_buffer.len() {
-                            self.cursor_position += 1;
-                        }
-                        Action::None
-                    }
+                    KeyCode::Right => {
+                        if self.cursor_position < self.input_buffer.len() {
+                            if let Some(next) = self.input_buffer[self.cursor_position..].chars().next() {
+                                self.cursor_position += next.len_utf8();
+                            } else {
+                                self.cursor_position = self.input_buffer.len();
+                            }
+                        }
+                        Action::None
+                    }

Also applies to: 970-981, 977-981, 983-988, 989-994

Comment on lines +183 to +200
/// Get the root project ID (top-level parent)
/// Since Todoist only has parent/child, root is either the project itself or its parent
fn get_root_project_id(&self, project: &ProjectDisplay) -> String {
project
.parent_id
.clone()
.unwrap_or_else(|| project.id.clone())
}

/// Get the root project (top-level parent) - always returns from self.projects
fn get_root_project(&self, project: &ProjectDisplay) -> &ProjectDisplay {
let root_id = self.get_root_project_id(project);
self.projects
.iter()
.find(|p| p.id == root_id)
.expect("Root project should exist in projects list")
}

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.

🛠️ Refactor suggestion

Avoid panic when resolving root project; support deeper hierarchies

expect will panic if parent not present; also get_root_project_id assumes max depth=1. Traverse parents and fall back gracefully.

-    /// Get the root project ID (top-level parent)
-    /// Since Todoist only has parent/child, root is either the project itself or its parent
-    fn get_root_project_id(&self, project: &ProjectDisplay) -> String {
-        project
-            .parent_id
-            .clone()
-            .unwrap_or_else(|| project.id.clone())
-    }
+    /// Get the top-level ancestor ID by walking parent links
+    fn get_root_project_id(&self, project: &ProjectDisplay) -> String {
+        let mut current = project;
+        while let Some(parent_id) = &current.parent_id {
+            if let Some(parent) = self.projects.iter().find(|p| p.id == *parent_id) {
+                current = parent;
+            } else {
+                break;
+            }
+        }
+        current.id.clone()
+    }
@@
-    fn get_root_project(&self, project: &ProjectDisplay) -> &ProjectDisplay {
-        let root_id = self.get_root_project_id(project);
-        self.projects
-            .iter()
-            .find(|p| p.id == root_id)
-            .expect("Root project should exist in projects list")
-    }
+    fn get_root_project(&self, project: &ProjectDisplay) -> &ProjectDisplay {
+        let root_id = self.get_root_project_id(project);
+        self.projects
+            .iter()
+            .find(|p| p.id == root_id)
+            .unwrap_or(project)
+    }
📝 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 the root project ID (top-level parent)
/// Since Todoist only has parent/child, root is either the project itself or its parent
fn get_root_project_id(&self, project: &ProjectDisplay) -> String {
project
.parent_id
.clone()
.unwrap_or_else(|| project.id.clone())
}
/// Get the root project (top-level parent) - always returns from self.projects
fn get_root_project(&self, project: &ProjectDisplay) -> &ProjectDisplay {
let root_id = self.get_root_project_id(project);
self.projects
.iter()
.find(|p| p.id == root_id)
.expect("Root project should exist in projects list")
}
/// Get the top-level ancestor ID by walking parent links
fn get_root_project_id(&self, project: &ProjectDisplay) -> String {
let mut current = project;
while let Some(parent_id) = &current.parent_id {
if let Some(parent) = self.projects.iter().find(|p| p.id == *parent_id) {
current = parent;
} else {
break;
}
}
current.id.clone()
}
/// Get the root project (top-level parent) - always returns from self.projects
fn get_root_project(&self, project: &ProjectDisplay) -> &ProjectDisplay {
let root_id = self.get_root_project_id(project);
self.projects
.iter()
.find(|p| p.id == root_id)
.unwrap_or(project)
}
🤖 Prompt for AI Agents
In src/ui/components/sidebar_component.rs around lines 183-200, the current code
assumes only one parent level and uses expect which can panic; update
get_root_project_id to iteratively walk the parent chain using self.projects to
look up each parent_id until you reach a project with no parent (or no matching
parent found) and return that root id (fallback to the original project.id if
lookup fails), and change get_root_project to avoid expect by returning either
an Option<&ProjectDisplay> or returning the input project reference when the
resolved root id isn't found in self.projects so the code no longer panics.

Comment thread src/ui/components/sidebar_component.rs
Comment thread src/ui/components/task_list_component.rs Outdated
Comment thread src/ui/components/task_list_component.rs Outdated
Comment thread src/ui/core/task_manager.rs Outdated
Comment thread src/ui/core/task_manager.rs
Comment thread src/ui/core/task_manager.rs Outdated

@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

♻️ Duplicate comments (1)
src/ui/core/task_manager.rs (1)

109-113: Project deletion navigation fix looks good.

Using starts_with("Delete project") resolves the prior dead branch.

🧹 Nitpick comments (10)
src/ui/core/task_manager.rs (8)

59-76: Don’t emit StartSync when a sync is already running.

Avoid flashing a “start” signal if SyncService reports in-progress; short-circuit and surface InProgress directly.

         let handle = tokio::spawn(async move {
-            // Send sync started notification
-            let _ = action_sender.send(Action::StartSync);
+            // If a sync is already running, don't emit StartSync again
+            if sync_service.is_syncing().await {
+                let status = SyncStatus::InProgress;
+                let _ = action_sender.send(Action::SyncCompleted(status.clone()));
+                return Ok(TaskResult::SyncCompleted(status));
+            }
+            // Send sync started notification
+            let _ = action_sender.send(Action::StartSync);
 
             match sync_service.force_sync().await {

98-101: Trim an extra clone in spawn_task_operation.

Only one clone is needed; move the original into the task and keep one clone for struct storage.

-        let action_sender = self.action_sender.clone();
-        let desc_clone = description.clone();
-        let desc_for_task = description.clone();
+        let action_sender = self.action_sender.clone();
+        let desc_for_task = description.clone();
+        let desc_clone = description; // moved into the task

140-161: Return real task results from finished handles (make async).

cleanup_finished_tasks discards actual outcomes. If callers use the returned Vec, await finished handles to propagate results.

-    pub fn cleanup_finished_tasks(&mut self) -> Vec<(TaskId, anyhow::Result<TaskResult>)> {
+    pub async fn cleanup_finished_tasks(&mut self) -> Vec<(TaskId, anyhow::Result<TaskResult>)> {
         let mut completed = Vec::new();
         let mut to_remove = Vec::new();
@@
-        for task_id in to_remove {
-            if let Some(_task) = self.tasks.remove(&task_id) {
-                // Since the task is finished, we'll just mark it as completed
-                // The actual result was already sent via the action channel
-                let result = Ok(TaskResult::Other("Task completed".to_string()));
-                completed.push((task_id, result));
-            }
-        }
+        for task_id in to_remove {
+            if let Some(task) = self.tasks.remove(&task_id) {
+                // Join is non-blocking here because is_finished() was true
+                let result = match task.handle.await {
+                    Ok(inner) => inner,
+                    Err(e) => Err(anyhow::anyhow!(format!("task join error: {}", e))),
+                };
+                completed.push((task_id, result));
+            }
+        }

Note: update call sites to await this method.


168-173: Avoid string-matching to detect sync tasks; track kind explicitly.

String contains("sync") is brittle. Add a TaskKind and set it at spawn sites.

 pub type TaskId = u64;
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TaskKind {
+    Sync,
+    Operation,
+    DataLoad,
+}
+
 #[derive(Debug)]
 pub struct BackgroundTask {
     pub id: TaskId,
-    pub handle: JoinHandle<anyhow::Result<TaskResult>>,
+    pub handle: JoinHandle<anyhow::Result<TaskResult>>,
     pub description: String,
     pub started_at: std::time::Instant,
+    pub kind: TaskKind,
 }
@@
         let task = BackgroundTask {
             id: task_id,
             handle,
             description,
             started_at: std::time::Instant::now(),
+            kind: TaskKind::Sync,
         };
@@
         let task = BackgroundTask {
             id: task_id,
             handle,
             description: desc_for_task,
             started_at: std::time::Instant::now(),
+            kind: TaskKind::Operation,
         };
@@
         let task = BackgroundTask {
             id: task_id,
             handle,
             description,
             started_at: std::time::Instant::now(),
+            kind: TaskKind::DataLoad,
         };
@@
     pub fn is_syncing(&self) -> bool {
-        self.tasks
-            .values()
-            .any(|task| task.description.contains("sync"))
+        self.tasks.values().any(|task| matches!(task.kind, TaskKind::Sync))
     }

Also applies to: 79-84, 129-136, 267-274, 7-15


205-265: Load projects/labels/sections concurrently.

These awaits are sequential; use try_join! to cut wait time.

-        let handle = tokio::spawn(async move {
-            match (
-                sync_service.get_projects().await,
-                sync_service.get_labels().await,
-                sync_service.get_sections().await,
-            ) {
-                (Ok(projects), Ok(labels), Ok(sections)) => {
+        let handle = tokio::spawn(async move {
+            let (projects, labels, sections) = match tokio::try_join!(
+                sync_service.get_projects(),
+                sync_service.get_labels(),
+                sync_service.get_sections(),
+            ) {
+                Ok(vals) => vals,
+                Err(e) => {
+                    let error_msg = format!("Failed to load data: {}", e);
+                    let _ = action_sender.send(Action::ShowDialog(
+                        crate::ui::core::actions::DialogType::Error(error_msg.clone()),
+                    ));
+                    return Ok(TaskResult::Other(error_msg));
+                }
+            };
-                    // Get tasks based on sidebar selection
+            // Get tasks based on sidebar selection
             let tasks = match sidebar_selection {

248-253: Minimize clones when emitting DataLoaded and returning TaskResult.

If TaskResult isn’t consumed, these clones are wasted. Either (a) drop TaskResult for fire-and-forget tasks, or (b) keep TaskResult but retrieve it via cleanup_finished_tasks and move values accordingly. Otherwise, consider wrapping in Arc to share.

Also applies to: 241-246


31-35: Public handle leaks cancellation outside the manager.

Expose id/description/timestamps, but keep handle private to avoid external aborts; manage cancellation via TaskManager API.

 pub struct TaskManager {
@@
 }
 
@@
 #[derive(Debug)]
 pub struct BackgroundTask {
     pub id: TaskId,
-    pub handle: JoinHandle<anyhow::Result<TaskResult>>,
+    handle: JoinHandle<anyhow::Result<TaskResult>>,
     pub description: String,
     pub started_at: std::time::Instant,
 }

If external UI needs read-only stats, add getters instead of exposing the handle.

Also applies to: 39-49


34-35: Unbounded action channel can grow without limit.

If the UI lags, actions may accumulate. Consider a small bounded channel with backpressure and .send().await to keep memory in check.

Also applies to: 39-49

src/icons.rs (2)

357-371: Tests cover all themes; minor DRY improvement.

Factor repeated assertions via a small helper to reduce duplication and ease future changes:

fn assert_day_icons(theme: IconTheme, today: &str, tomorrow: &str) {
    let s = IconService::new(theme);
    assert_eq!(s.today(), today);
    assert_eq!(s.tomorrow(), tomorrow);
}

304-311: Unify calendar icons via IconService and remove hard-coded glyphs

  • Change today() to delegate to self.icons().status.due_date (and add a matching field or helper for tomorrow in IconSet/IconService) to centralize theme mappings.
  • Replace literal "📅 Today" and "📅 Tomorrow" in src/ui/components/task_list_component.rs (lines 179 & 208) with calls to app.icons.today()/app.icons.tomorrow() so the UI always uses the current theme.
  • Audit other hard-coded “Today”/“Tomorrow” icons across UI components and swap in the service methods for consistency.
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between aa4ebfe and fe3236e.

📒 Files selected for processing (3)
  • src/icons.rs (2 hunks)
  • src/ui/components/sidebar_component.rs (1 hunks)
  • src/ui/core/task_manager.rs (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/ui/components/sidebar_component.rs
🧰 Additional context used
🧬 Code graph analysis (2)
src/icons.rs (3)
src/ui/components/sidebar_component.rs (1)
  • new (28-35)
src/ui/components/tasks_list.rs (2)
  • create_tomorrow_task_items (220-237)
  • create_today_task_items (150-217)
src/ui/app.rs (1)
  • next_sidebar_item (308-364)
src/ui/core/task_manager.rs (3)
src/sync.rs (4)
  • sync (353-373)
  • new (29-40)
  • is_syncing (123-125)
  • SyncService (27-493)
src/ui/app_component.rs (3)
  • new (78-101)
  • spawn_task_operation (600-742)
  • is_syncing (113-115)
src/todoist.rs (1)
  • TaskDisplay (30-45)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Security Audit

Comment on lines +197 to +206
/// Spawn a background data loading operation
pub fn spawn_data_load(&mut self, sync_service: SyncService, sidebar_selection: SidebarSelection) -> TaskId {
let task_id = self.next_task_id;
self.next_task_id += 1;

let action_sender = self.action_sender.clone();
let description = "Loading data from storage".to_string();

let handle = tokio::spawn(async move {
match (

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.

💡 Verification agent

🧩 Analysis chain

Guard against stale DataLoaded from out-of-order completions.

Multiple overlapping data loads can race; an older task finishing last may overwrite newer state. Consider canceling prior data-load tasks before spawning, or tagging loads with a generation and ignoring stale ones in the reducer.

Run to inspect DataLoaded handling and any generation checks:

Expected: a single in-flight data-load policy or a generation check at the action consumer.


🏁 Script executed:

#!/bin/bash
set -e
rg -nP -C3 'Action::DataLoaded\s*\{' -g '*.rs' src
rg -n 'spawn_data_load\(' -C2 -g '*.rs' src

Length of output: 1698


Prevent stale DataLoaded actions from overwriting newer data.

  • Overlapping calls to spawn_data_load (src/ui/core/task_manager.rs:198–206) can complete out of order and send an old Action::DataLoaded (sent at line 248), overwriting more recent updates.
  • Either cancel any in-flight load before spawning a new one, or attach a monotonically increasing generation/tag to each load and have the reducer (in src/ui/app_component.rs:489) discard actions whose tag doesn’t match the latest.
🤖 Prompt for AI Agents
In src/ui/core/task_manager.rs around lines 197–206, overlapping spawn_data_load
calls can complete out-of-order and send stale Action::DataLoaded that
overwrites newer state; fix by adding a monotonically increasing generation/tag:
add a generation counter field to the TaskManager, increment it when spawning,
capture the current generation in the async task and include it in the
DataLoaded action payload, then update the reducer in src/ui/app_component.rs to
ignore DataLoaded actions whose tag doesn't match the TaskManager's latest
generation. Ensure the generation is updated atomically with next_task_id so
each load has a unique tag and no extra blocking/cancellation logic is required.

I know this is not the rust way to do things, but I'm just trying to
get comfy with rust for now.

@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: 2

♻️ Duplicate comments (2)
src/todoist.rs (1)

12-13: Good addition: surface inbox flag in the display model.

Ensure every ProjectDisplay literal across the codebase (e.g., storage mappings, UI components) sets this new field to avoid defaulting to false implicitly.

Run:

#!/bin/bash
# Verify all ProjectDisplay literals include is_inbox_project
rg -nP --type=rust -C2 'ProjectDisplay\s*\{'
tests/todoist_test.rs (1)

20-25: Add an assertion for the inbox flag.

Covers the newly added field on ProjectDisplay as previously requested.

     let display: ProjectDisplay = project.into();
     assert_eq!(display.id, "123");
     assert_eq!(display.name, "Test Project");
     assert_eq!(display.color, "blue");
     assert!(display.is_favorite);
+    assert!(!display.is_inbox_project);
🧹 Nitpick comments (17)
tests/date_utils_test.rs (2)

1-3: Use explicit imports instead of a glob.

Keeps the test namespace clean and catches accidental re-exports.

-use terminalist::utils::date::*;
+use terminalist::utils::date::{format_ymd, next_weekday};

5-8: Good baseline; consider a leap-day case.

Add a Feb 29 case to harden formatting coverage.

@@
 #[test]
 fn test_format_ymd() {
     let date = NaiveDate::from_ymd_opt(2023, 12, 25).unwrap();
     assert_eq!(format_ymd(date), "2023-12-25");
 }
+
+#[test]
+fn test_format_ymd_leap_day() {
+    let date = NaiveDate::from_ymd_opt(2024, 2, 29).unwrap();
+    assert_eq!(format_ymd(date), "2024-02-29");
+}
tests/icons_test.rs (2)

18-24: Deduplicate per-theme status icon tests

The three blocks are near-identical; consider a small table-driven helper to cut repetition and make future theme additions cheaper.

Example helper-based approach:

fn assert_status_icons(theme: IconTheme, pending: &str, completed: &str, deleted: &str) {
    let svc = IconService::new(theme);
    assert_eq!(svc.task_pending(), pending);
    assert_eq!(svc.task_completed(), completed);
    assert_eq!(svc.task_deleted(), deleted);
}

And call it for each theme in one test.

Also applies to: 26-32, 34-40


42-55: Also assert “today” stays in sync with StatusIcons.due_date

This guards against drift between today() and the theme’s due_date glyph.

Apply this diff to extend the assertions:

@@
     let emoji_service = IconService::new(IconTheme::Emoji);
     assert_eq!(emoji_service.today(), "📅");
     assert_eq!(emoji_service.tomorrow(), "🗓️");
+    assert_eq!(emoji_service.today(), emoji_service.icons().status.due_date);

@@
     let unicode_service = IconService::new(IconTheme::Unicode);
     assert_eq!(unicode_service.today(), "◷");
     assert_eq!(unicode_service.tomorrow(), "◶");
+    assert_eq!(unicode_service.today(), unicode_service.icons().status.due_date);

@@
     let ascii_service = IconService::new(IconTheme::Ascii);
     assert_eq!(ascii_service.today(), "@");
     assert_eq!(ascii_service.tomorrow(), "+");
+    assert_eq!(ascii_service.today(), ascii_service.icons().status.due_date);
src/icons.rs (1)

304-310: Add a brief doc comment for the new tomorrow() API

Public API looks good and matches tests. Add a one-liner doc to keep consistency with other convenience methods.

-    #[must_use]
-    pub fn tomorrow(&self) -> &'static str {
+    /// Icon for "tomorrow" (next day) in the current theme.
+    #[must_use]
+    pub fn tomorrow(&self) -> &'static str {
         match self.current_theme {
             IconTheme::Emoji => "🗓️",
             IconTheme::Unicode => "◶",
             IconTheme::Ascii => "+",
         }
     }
src/todoist.rs (2)

73-110: Trim clones and remove magic string in Task → TaskDisplay conversion.

  • Destructure task to move fields and avoid cloning due/deadline strings.
  • Replace hardcoded "blue" with a module-level constant.

Example sketch:

const DEFAULT_LABEL_COLOR: &str = "blue";

impl From<Task> for TaskDisplay {
    fn from(task: Task) -> Self {
        let Task { id, content, project_id, section_id, is_completed, priority,
                   due, deadline, duration, labels, description, .. } = task;

        let duration = duration.map(|d| match d.unit.as_str() {
            "minute" => format!("{}m", d.amount),
            "hour"   => format!("{}h", d.amount),
            "day"    => format!("{}d", d.amount),
            _ => format!("{} {}", d.amount, d.unit),
        });

        let (due, due_datetime, is_recurring) = match due {
            Some(d) => (Some(d.date), d.datetime, d.is_recurring),
            None => (None, None, false),
        };

        let labels = labels.into_iter().map(|name| {
            let id = name.clone();
            LabelDisplay { id, name, color: DEFAULT_LABEL_COLOR.to_string() }
        }).collect();

        Self { id, content, project_id, section_id, is_completed, is_deleted: false,
               priority, due, due_datetime, is_recurring, deadline, duration, labels, description }
    }
}

1-2: Consider narrowing the public re-export.

Re-exporting todoist_api::* leaks the entire external API to your consumers. Prefer a curated prelude or explicit re-exports of only used types to keep your public surface stable.

tests/todoist_test.rs (3)

58-67: Strengthen Task conversion assertions.

Validate all mapped fields and the defaulted flags to prevent regressions.

     let display: TaskDisplay = task.into();
     assert_eq!(display.id, "456");
     assert_eq!(display.content, "Test Task");
     assert_eq!(display.project_id, "123");
     assert_eq!(display.priority, 3);
     assert!(!display.is_completed);
     assert!(display.is_recurring);
     assert_eq!(display.labels.len(), 2);
     assert_eq!(display.duration, Some("30m".to_string()));
+    assert_eq!(display.due, Some("2023-01-02".to_string()));
+    assert_eq!(display.due_datetime, None);
+    assert_eq!(display.deadline, None);
+    assert_eq!(display.section_id, None);
+    assert_eq!(display.description, "Test Description");
+    assert!(!display.is_deleted);
+    assert!(display.labels.iter().all(|l| l.color == "blue"));

1-1: Reduce coupling to the crate’s re-export.

Importing from terminalist::todoist::* ties tests to the module’s public surface. Import only the display types from terminalist and construct domain types directly from todoist_api if feasible.


27-67: Add a small Section conversion test.

Covers Section → SectionDisplay mapping and ordering.

I can push a follow-up test like:

#[test]
fn test_section_conversion() {
    let section = Section { id: "s1".into(), name: "Inbox".into(), project_id: "123".into(), order: 7 };
    let display: SectionDisplay = section.into();
    assert_eq!(display.id, "s1");
    assert_eq!(display.name, "Inbox");
    assert_eq!(display.project_id, "123");
    assert_eq!(display.order, 7);
}

Do you want me to open a PR task for this?

tests/ui_renderer_test.rs (3)

3-9: Ignore or implement the placeholder test

This test does nothing; keep the intent visible without running it in CI.

-#[tokio::test]
-async fn test_app_creation() {
+#[tokio::test]
+#[ignore = "Needs mock SyncService/AppComponent; implement after wiring settles"]
+async fn test_app_creation() {
     // This would require a mock sync service
     // let sync_service = SyncService::new("dummy_token".to_string()).await.unwrap();
     // let app = AppComponent::new(sync_service);
     // assert!(!app.should_quit());
 }

11-22: Make the render-gate timing test deterministic (or add margin)

Sleeping 17 ms is brittle under scheduler jitter. Prefer virtual time; otherwise, add headroom.

Option A (preferred; works if EventHandler uses tokio::time::Instant):

-#[tokio::test]
+#[tokio::test(start_paused = true)]
 async fn test_event_handling() {
     // Test that the event handler can be created
     let event_handler = EventHandler::new();

     // Initially should not need to render (just created)
     assert!(!event_handler.should_render());

-    // After waiting, should be ready to render
-    tokio::time::sleep(tokio::time::Duration::from_millis(17)).await;
-    assert!(event_handler.should_render());
+    // Advance virtual time to exercise the 16 ms gate deterministically
+    tokio::time::advance(tokio::time::Duration::from_millis(15)).await;
+    assert!(!event_handler.should_render());
+    tokio::time::advance(tokio::time::Duration::from_millis(1)).await;
+    assert!(event_handler.should_render());
 }

Option B (fallback if EventHandler uses std::time::Instant):

-    tokio::time::sleep(tokio::time::Duration::from_millis(17)).await;
+    tokio::time::sleep(tokio::time::Duration::from_millis(30)).await; // add jitter headroom

To confirm which clock is used, check EventHandler’s last-render timestamp type (tokio::time::Instant vs std::time::Instant).


11-12: Rename test to reflect intent

Clearer, behavior-focused name.

-async fn test_event_handling() {
+async fn test_event_handler_should_render_gate() {
src/ui/new_renderer.rs (4)

32-41: Remove unused render/cleanup intervals and simplify the loop signature.

The explicit cleanup_interval and render_interval are created but never used (and are passed into _-prefixed params). Since EventHandler already owns timing, drop these to avoid confusion and needless timers.

Apply:

- use tokio::time::{interval, Duration};
+ // Timing is managed by EventHandler; no external intervals needed.

@@
-    // Create intervals for periodic tasks
-    let mut cleanup_interval = interval(Duration::from_secs(5)); // Clean up finished tasks every 5 seconds
-    let mut render_interval = interval(Duration::from_millis(16)); // ~60 FPS rendering
-    let result = run_app_loop(
-        &mut terminal,
-        &mut app,
-        &mut event_handler,
-        &mut cleanup_interval,
-        &mut render_interval,
-    )
-    .await;
+    // Drive the main loop (EventHandler manages ticks/renders)
+    let result = run_app_loop(&mut terminal, &mut app, &mut event_handler).await;

@@
-async fn run_app_loop<B: Backend>(
-    terminal: &mut Terminal<B>,
-    app: &mut AppComponent,
-    event_handler: &mut EventHandler,
-    _cleanup_interval: &mut tokio::time::Interval,
-    _render_interval: &mut tokio::time::Interval,
-) -> anyhow::Result<()> {
+async fn run_app_loop<B: Backend>(
+    terminal: &mut Terminal<B>,
+    app: &mut AppComponent,
+    event_handler: &mut EventHandler,
+) -> anyhow::Result<()> {

Also applies to: 52-58


19-24: Attach error context to terminal lifecycle ops for easier debugging.

Adding anyhow::Context to the raw-mode, alt-screen, and terminal ops yields actionable error messages (what failed and where).

Apply:

+use anyhow::Context;
@@
-    enable_raw_mode()?;
+    enable_raw_mode().context("enabling raw mode")?;
@@
-    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
+    execute!(stdout, EnterAlternateScreen, EnableMouseCapture).context("entering alternate screen")?;
@@
-    let mut terminal = Terminal::new(backend)?;
+    let mut terminal = Terminal::new(backend).context("creating ratatui terminal")?;
@@
-    disable_raw_mode()?;
-    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
+    disable_raw_mode().context("disabling raw mode")?;
+    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture).context("leaving alternate screen")?;

Also applies to: 45-47


135-139: Implement request_render instead of a no-op.

Callers expecting this to force a redraw will be surprised. Minimal approach: track a flag in AppComponent and let the loop consume it.

Example (requires small additions in AppComponent):

// in AppComponent
pub(crate) fn request_render(&mut self) { self.state.render_requested = true; }
pub(crate) fn take_render_request(&mut self) -> bool {
    let was = self.state.render_requested;
    self.state.render_requested = false;
    was
}

Then in the loop, after handling an event:

if app.take_render_request() {
    needs_render = true;
}

123-133: Surface a real last_sync in AppStatus.

last_sync: None limits usefulness. After a successful sync, store SystemTime::now() in AppComponent and return it here.

Example:

 pub fn get_status(&self) -> AppStatus {
     AppStatus {
         active_tasks: self.active_task_count(),
         is_syncing: self.is_syncing(),
-        last_sync: None, // TODO: Track last sync time
+        last_sync: self.last_sync_time(), // e.g., Option<SystemTime> maintained by AppComponent
         total_tasks: self.total_tasks(),
         total_projects: self.total_projects(),
     }
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fe3236e and 5b2dffc.

📒 Files selected for processing (10)
  • src/icons.rs (1 hunks)
  • src/todoist.rs (2 hunks)
  • src/ui/components/task_list_component.rs (1 hunks)
  • src/ui/core/task_manager.rs (1 hunks)
  • src/ui/new_renderer.rs (1 hunks)
  • src/utils/date.rs (0 hunks)
  • tests/date_utils_test.rs (1 hunks)
  • tests/icons_test.rs (1 hunks)
  • tests/todoist_test.rs (1 hunks)
  • tests/ui_renderer_test.rs (1 hunks)
💤 Files with no reviewable changes (1)
  • src/utils/date.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/ui/core/task_manager.rs
  • src/ui/components/task_list_component.rs
🧰 Additional context used
🧬 Code graph analysis (4)
tests/icons_test.rs (2)
src/icons.rs (10)
  • icons (114-120)
  • default (20-22)
  • default (80-82)
  • theme (94-96)
  • new (88-90)
  • task_pending (223-225)
  • task_completed (228-230)
  • task_deleted (233-235)
  • today (296-302)
  • tomorrow (305-311)
src/ui/components/task_list_component.rs (2)
  • default (32-34)
  • new (38-49)
tests/ui_renderer_test.rs (1)
src/ui/core/event_handler.rs (1)
  • should_render (44-46)
tests/date_utils_test.rs (1)
src/utils/date.rs (2)
  • format_ymd (6-8)
  • next_weekday (11-19)
src/ui/new_renderer.rs (5)
src/sync.rs (2)
  • sync (353-373)
  • new (29-40)
src/ui/app_component.rs (1)
  • new (78-101)
src/ui/core/event_handler.rs (1)
  • new (13-19)
src/ui/renderer.rs (3)
  • run_ui (70-123)
  • run_app (26-67)
  • render_ui (126-192)
src/ui/app.rs (2)
  • App (79-1233)
  • App (20-71)
🔇 Additional comments (8)
tests/date_utils_test.rs (2)

11-16: LGTM: correct Friday → Monday advance.

Matches strict “next” semantics of next_weekday.


18-24: LGTM: same-day goes to next week.

Asserts the intended delta=7 behavior when weekdays match.

tests/icons_test.rs (3)

3-7: Solid default-theme assertion

Matches IconTheme::default() implementation. No changes needed.


10-16: Theme switching covered

Happy path for set_theme is exercised and aligned with cycle order.


57-70: Cycle order verified

Covers the full wrap-around Ascii → Unicode → Emoji → Ascii.

src/todoist.rs (1)

57-58: LGTM: correct propagation from Project → ProjectDisplay.

tests/ui_renderer_test.rs (1)

16-18: Initial assertion looks good

The “fresh instance ⇒ no render” check matches the 16 ms threshold logic.

src/ui/new_renderer.rs (1)

61-67: LGTM: render only when needed.

The needs_render gate avoids redundant draws and pairs well with the EventType::Render and background-action-driven renders.

Comment thread src/ui/new_renderer.rs
Comment on lines +4 to +8
use crossterm::{
event::DisableMouseCapture,
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};

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.

🛠️ Refactor suggestion

Make terminal setup/teardown symmetric (enable mouse capture, hide cursor, clear screen).

We disable mouse capture and show the cursor on exit but never enable/hide them on entry. This causes inconsistent UX and can leave the cursor visible during rendering.

Apply:

 use crossterm::{
-    event::DisableMouseCapture,
+    event::{DisableMouseCapture, EnableMouseCapture},
     execute,
     terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
 };
@@
-    execute!(stdout, EnterAlternateScreen)?;
+    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
     let backend = CrosstermBackend::new(stdout);
     let mut terminal = Terminal::new(backend)?;
+    terminal.hide_cursor()?;
+    terminal.clear()?;
@@
-    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
+    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
     terminal.show_cursor()?;

Also applies to: 21-24, 45-47

🤖 Prompt for AI Agents
In src/ui/new_renderer.rs around lines 4-8 (and similarly adjust lines 21-24 and
45-47), the terminal setup/teardown is asymmetric: exit path disables mouse
capture and shows the cursor but the entry path does not enable mouse capture,
hide the cursor, or clear the screen. Update the entry/initialization sequence
to enable mouse capture, hide the cursor, and clear/enter the alternate screen
(alongside existing enable_raw_mode and EnterAlternateScreen), and ensure the
teardown still symmetrically disables mouse capture, shows the cursor, and
leaves/clears the alternate screen so setup and teardown are consistent.

Comment thread src/ui/new_renderer.rs
Comment on lines +16 to +31
/// Enhanced async event loop with proper background task support
pub async fn run_new_app(sync_service: SyncService) -> anyhow::Result<()> {
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;

// Initialize application components
let mut app = AppComponent::new(sync_service);
let mut event_handler = EventHandler::new();

// Start initial sync automatically
app.trigger_initial_sync();

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.

🛠️ Refactor suggestion

Guard against early errors leaving the terminal in a bad state.

If a failure occurs after raw mode is enabled but before the explicit teardown, the terminal can be left in raw mode. Consider a small Drop guard that always restores state.

Add this helper (can live in this module):

struct TuiGuard;

impl Drop for TuiGuard {
    fn drop(&mut self) {
        let _ = crossterm::terminal::disable_raw_mode();
        let _ = crossterm::execute!(std::io::stdout(), crossterm::terminal::LeaveAlternateScreen, crossterm::event::DisableMouseCapture);
    }
}

Then at the top of run_new_app after enabling raw mode and entering the alt screen:

let _tui_guard = TuiGuard; // ensures cleanup on early-return/panic

You can keep the explicit teardown; the guard makes cleanup robust on early errors.

🤖 Prompt for AI Agents
In src/ui/new_renderer.rs around lines 16 to 31, enabling raw mode and entering
the alternate screen can leave the terminal in a broken state if an error occurs
before the explicit teardown; add a small Drop guard type in this module that
disables raw mode, leaves the alternate screen and disables mouse capture in its
Drop implementation, then instantiate it (e.g., let _tui_guard = TuiGuard;)
immediately after enable_raw_mode() and EnterAlternateScreen so the terminal is
always restored on early returns or panics while keeping the existing explicit
teardown at the end.

@romaintb
romaintb merged commit ac7cbb0 into main Sep 6, 2025
4 of 5 checks passed
@romaintb
romaintb deleted the refactor/global_architecture branch September 6, 2025 16:02
This was referenced Sep 7, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant