Skip to main content

kasl/libs/
view.rs

1//! Console display and table formatting system.
2//!
3//! Provides interface for rendering application data in well-formatted console tables.
4//! Handles presentation layer for work reports, task lists, summaries, templates, and tags.
5//!
6//! ## Features
7//!
8//! - **Structured Data Display**: Converts complex data structures into readable tables
9//! - **Consistent Formatting**: Maintains uniform appearance across all table types
10//! - **Report Visualization**: Displays pre-calculated productivity and work metrics
11//! - **Duration Formatting**: Handles time duration display in human-readable formats
12//!
13//! ## Usage
14//!
15//! ```rust,no_run
16//! # fn f() -> anyhow::Result<()> {
17//! use kasl::libs::view::View;
18//! use kasl::libs::task::Task;
19//! use kasl::db::workdays::Workday;
20//! use kasl::libs::report::WorkInterval;
21//! use chrono::{Local, TimeDelta};
22//! use std::collections::HashMap;
23//!
24//! let tasks: Vec<Task> = vec![];
25//! let workday = Workday {
26//!     id: 1,
27//!     date: Local::now().date_naive(),
28//!     start: Local::now().naive_local(),
29//!     end: None,
30//! };
31//! let intervals: Vec<WorkInterval> = vec![];
32//! let filtered_duration = TimeDelta::zero();
33//! let productivity = 0.0_f64;
34//! let summary_data = (HashMap::new(), String::new(), String::new());
35//!
36//! View::tasks(&tasks)?;
37//! View::report(&workday, &intervals, &filtered_duration, &productivity, &tasks)?;
38//! View::sum(&summary_data)?;
39//! # Ok(())
40//! # }
41//! ```
42
43use super::task::Task;
44use crate::db::templates::TaskTemplate;
45use crate::db::workdays::Workday;
46use crate::libs::formatter::{format_duration, terminal_cols, truncate_to_width};
47use crate::libs::messages::Message;
48use crate::libs::pause::Pause;
49use crate::libs::report;
50use crate::msg_print;
51use anyhow::Result;
52use chrono::{Duration, NaiveDate, TimeDelta};
53use prettytable::{Cell, Row, Table, format, row};
54use std::collections::HashMap;
55use unicode_width::UnicodeWidthStr;
56
57/// A utility struct for rendering application data to the console.
58///
59/// Serves as a namespace for various table rendering functions. All methods are static,
60/// making it easy to call formatting functions without needing to instantiate the struct.
61pub struct View {}
62
63impl View {
64    /// Displays a formatted table of tasks with comprehensive metadata.
65    ///
66    /// Renders a detailed table showing task information including identification numbers,
67    /// names, completion status, comments, and associated tags.
68    ///
69    /// # Arguments
70    ///
71    /// * `tasks` - A slice of `Task` structs to display in the table
72    ///
73    /// # Returns
74    ///
75    /// Returns `Ok(())` on successful table rendering, or an error if
76    /// the table cannot be displayed due to terminal or formatting issues.
77    pub fn tasks(tasks: &[Task]) -> Result<()> {
78        let show_task_id = tasks.iter().any(|t| t.task_id.is_some_and(|id| id != 0));
79        let show_comment = tasks.iter().any(|t| !t.comment.trim().is_empty());
80        let show_tags = tasks.iter().any(|t| !t.tags.is_empty());
81
82        let idx_width = tasks.len().to_string().width().max("#".width());
83        let id_width = tasks.iter().map(|t| t.id.unwrap_or(0).to_string().width()).max().unwrap_or(1).max("ID".width());
84        let task_id_width = if show_task_id {
85            tasks
86                .iter()
87                .map(|t| t.task_id.unwrap_or(0).to_string().width())
88                .max()
89                .unwrap_or(1)
90                .max("TASK ID".width())
91        } else {
92            0
93        };
94        let done_width = "DONE".width().max("100%".width());
95
96        // prettytable cell format: `| content |` → 3 chars overhead per column + 1 outer border
97        let mut num_cols = 4; // #, ID, NAME, DONE
98        if show_task_id {
99            num_cols += 1;
100        }
101        if show_comment {
102            num_cols += 1;
103        }
104        if show_tags {
105            num_cols += 1;
106        }
107
108        let mut fixed_content = idx_width + id_width + done_width;
109        if show_task_id {
110            fixed_content += task_id_width;
111        }
112
113        let frame_overhead = 3 * num_cols + 1;
114        let mut flexible = terminal_cols().saturating_sub(frame_overhead + fixed_content);
115
116        // Reserve a modest slice for optional text columns; NAME gets the rest.
117        let tags_width = if show_tags {
118            let width = (flexible / 5).clamp(8, 20);
119            flexible = flexible.saturating_sub(width);
120            width
121        } else {
122            0
123        };
124        let comment_width = if show_comment {
125            let width = (flexible / 3).clamp(12, 40);
126            flexible = flexible.saturating_sub(width);
127            width
128        } else {
129            0
130        };
131        let name_width = flexible.max(12);
132
133        let mut table = Table::new();
134        table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
135
136        let mut titles = vec![Cell::new("#"), Cell::new("ID")];
137        if show_task_id {
138            titles.push(Cell::new("TASK ID"));
139        }
140        titles.push(Cell::new("NAME"));
141        if show_comment {
142            titles.push(Cell::new("COMMENT"));
143        }
144        titles.push(Cell::new("DONE"));
145        if show_tags {
146            titles.push(Cell::new("TAGS"));
147        }
148        table.set_titles(Row::new(titles));
149
150        for (index, task) in tasks.iter().enumerate() {
151            let mut cells = vec![Cell::new(&(index + 1).to_string()), Cell::new(&task.id.unwrap_or(0).to_string())];
152            if show_task_id {
153                cells.push(Cell::new(&task.task_id.unwrap_or(0).to_string()));
154            }
155            cells.push(Cell::new(&truncate_to_width(&task.name, name_width)));
156            if show_comment {
157                cells.push(Cell::new(&truncate_to_width(task.comment.trim(), comment_width)));
158            }
159            cells.push(Cell::new(&format!("{}%", task.completeness.unwrap_or(100))));
160            if show_tags {
161                let tags_str = task.tags.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ");
162                cells.push(Cell::new(&truncate_to_width(&tags_str, tags_width)));
163            }
164            table.add_row(Row::new(cells));
165        }
166
167        table.printstd();
168        Ok(())
169    }
170
171    /// Displays a formatted daily work report using pre-calculated intervals.
172    ///
173    /// This method displays the core report data in a structured table format,
174    /// including work intervals, total time, and productivity metrics calculated
175    /// using the centralized Productivity module.
176    ///
177    /// ## Display Components
178    ///
179    /// 1. **Work Intervals**: Detailed breakdown of focused work periods
180    /// 2. **Total Duration**: Sum of all work intervals (may be filtered)
181    /// 3. **Productivity Percentage**: Calculated using comprehensive Productivity logic
182    /// 4. **Associated Tasks**: Tasks completed during the workday for context
183    ///
184    /// The productivity value displayed here is calculated using the same centralized
185    /// logic used throughout the application for consistency.
186    ///
187    /// # Arguments
188    ///
189    /// * `workday` - The workday record containing start/end times
190    /// * `intervals` - Pre-calculated and optionally filtered work intervals for display
191    /// * `filtered_duration` - Sum of displayed interval durations
192    /// * `productivity` - Productivity percentage from centralized Productivity calculation
193    /// * `tasks` - Tasks completed during the workday for context
194    pub fn report(workday: &Workday, intervals: &[report::WorkInterval], filtered_duration: &TimeDelta, productivity: &f64, tasks: &[Task]) -> Result<()> {
195        // Display formatted report header with readable date
196        msg_print!(Message::ReportHeader(workday.date.format("%B %-d, %Y").to_string()), true);
197
198        // Create and populate the work intervals table
199        let mut table = Table::new();
200        table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
201        table.set_titles(row!["ID", "START", "END", "DURATION"]);
202
203        // Add each work interval as a table row with formatted times
204        for (index, interval) in intervals.iter().enumerate() {
205            table.add_row(row![
206                index + 1,                           // Sequential numbering for easy reference
207                interval.start.format("%H:%M"),      // Start time in HH:MM format
208                interval.end.format("%H:%M"),        // End time in HH:MM format
209                format_duration(&interval.duration)  // Human-readable duration
210            ]);
211        }
212
213        // Add summary rows with total time and productivity metrics
214        table.add_empty_row(); // Visual separator before summary
215        table.add_row(row!["TOTAL", "", "", format_duration(filtered_duration)]);
216        table.add_row(row!["PRODUCTIVITY", "", "", format!("{:.1}%", productivity)]);
217
218        // Render the intervals table to console
219        table.printstd();
220
221        // Display associated tasks if any were completed during the day
222        if !tasks.is_empty() {
223            msg_print!(Message::TasksHeader, true);
224            Self::tasks(tasks)?;
225        }
226
227        Ok(())
228    }
229
230    /// Displays a monthly summary of working hours with daily breakdowns.
231    ///
232    /// This method renders a comprehensive monthly view that shows daily work
233    /// patterns, totals, and averages. It provides both detailed daily data
234    /// and aggregate statistics to help users understand their work patterns
235    /// over the entire month.
236    ///
237    /// ## Summary Structure
238    ///
239    /// The monthly summary includes:
240    /// - **Daily Breakdown**: Each day with date, hours worked, and workday status
241    /// - **Total Hours**: Cumulative time worked across all days in the month
242    /// - **Average Hours**: Mean daily working time for better pattern analysis
243    /// - **Work Days**: Count of days with recorded work activity
244    ///
245    /// ## Data Interpretation
246    ///
247    /// - **Workday Hours**: Actual time recorded for productive work days
248    /// - **Rest Day Hours**: Default hours applied to weekends and holidays
249    /// - **Missing Days**: Days without any recorded activity (shown as 0:00)
250    ///
251    /// # Arguments
252    ///
253    /// * `summary_data` - A tuple containing:
254    ///   - `HashMap<NaiveDate, (String, String)>`: Daily durations and productivity data
255    ///   - `String`: Total duration for the entire month
256    ///   - `String`: Average daily duration across all days
257    ///
258    /// # Returns
259    ///
260    /// Returns `Ok(())` on successful summary display, or an error if
261    /// table formatting or rendering fails.
262    ///
263    /// # Examples
264    ///
265    /// ```rust,no_run
266    /// # fn f() -> anyhow::Result<()> {
267    /// use kasl::libs::view::View;
268    /// use std::collections::HashMap;
269    ///
270    /// let daily_map = HashMap::new();
271    /// let total_hours = String::new();
272    /// let average_hours = String::new();
273    ///
274    /// let summary_data = (daily_map, total_hours, average_hours);
275    /// View::sum(&summary_data)?;
276    /// # Ok(())
277    /// # }
278    /// ```
279    pub fn sum((daily_durations, total_duration, average_duration): &(HashMap<NaiveDate, (String, String)>, String, String)) -> Result<()> {
280        // Initialize table with appropriate formatting for summary data
281        let mut table: Table = Table::new();
282        table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
283        table.set_titles(row!["DATE", "HOURS", "PRODUCTIVITY"]);
284
285        // Sort dates chronologically for logical display order
286        let mut sorted_dates: Vec<&NaiveDate> = daily_durations.keys().collect();
287        sorted_dates.sort();
288
289        // Add each day's data as a table row
290        for date in sorted_dates {
291            if let Some((duration, productivity)) = daily_durations.get(date) {
292                table.add_row(row![
293                    date.format("%Y-%m-%d"), // ISO date format for consistency
294                    duration,                // Formatted duration string
295                    productivity             // Productivity percentage or status
296                ]);
297            }
298        }
299
300        // Add summary statistics with visual separation
301        table.add_empty_row(); // Visual separator before totals
302        table.add_row(row!["TOTAL", total_duration, ""]);
303        table.add_row(row!["AVERAGE", average_duration, ""]);
304
305        // Render the summary table to console
306        table.printstd();
307        Ok(())
308    }
309
310    /// Displays a table of pauses for a given day with total pause time.
311    ///
312    /// # Arguments
313    /// * `pauses` - A slice of `Pause` records to display.
314    /// * `total_pause_time` - The total duration of all pauses.
315    ///
316    /// # Returns
317    /// A `Result` indicating success.
318    pub fn pauses(pauses: &[Pause], total_pause_time: Duration) -> Result<()> {
319        let mut table = Table::new();
320        table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
321        table.set_titles(row!["ID", "START", "END", "DURATION"]);
322
323        for (i, b) in pauses.iter().enumerate() {
324            table.add_row(row![
325                i + 1,
326                b.start.format("%H:%M"),
327                b.end.map(|t| t.format("%H:%M").to_string()).unwrap_or_else(|| "-".to_string()),
328                b.duration
329                    .map(|duration: TimeDelta| format_duration(&duration))
330                    .unwrap_or_else(|| "--:--".to_string())
331            ]);
332        }
333
334        // Add total row
335        if !pauses.is_empty() {
336            table.add_empty_row();
337            table.add_row(row!["TOTAL", "", "", format_duration(&total_pause_time)]);
338        }
339
340        table.printstd();
341        Ok(())
342    }
343
344    /// Displays a formatted table of task templates for reusable task creation.
345    ///
346    /// This method renders a comprehensive view of all available task templates,
347    /// showing their configuration and usage information. Templates provide a
348    /// convenient way to create commonly used tasks with pre-filled parameters.
349    ///
350    /// ## Template Information
351    ///
352    /// The table displays essential template metadata:
353    /// - **Template Name**: Unique identifier for template selection
354    /// - **Task Name**: Default task title that will be used
355    /// - **Comment**: Pre-configured task description or notes
356    /// - **Completeness**: Default completion percentage for new tasks
357    ///
358    /// ## Usage Context
359    ///
360    /// Templates are particularly useful for:
361    /// - Recurring tasks with standard parameters
362    /// - Team workflows with consistent task structures
363    /// - Quick task creation with minimal input required
364    /// - Standardized task naming and completion patterns
365    ///
366    /// # Arguments
367    ///
368    /// * `templates` - A slice of `TaskTemplate` structs to display
369    ///
370    /// # Returns
371    ///
372    /// Returns `Ok(())` on successful table rendering, or an error if
373    /// display operations fail.
374    ///
375    /// # Examples
376    ///
377    /// ```rust,no_run
378    /// # fn f() -> anyhow::Result<()> {
379    /// use kasl::libs::view::View;
380    /// use kasl::db::templates::TaskTemplate;
381    ///
382    /// let templates: Vec<TaskTemplate> = vec![/* template instances */];
383    /// View::templates(&templates)?;
384    /// # Ok(())
385    /// # }
386    /// ```
387    pub fn templates(templates: &[TaskTemplate]) -> Result<()> {
388        // Initialize table with clean formatting for template data
389        let mut table = Table::new();
390        table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
391        table.set_titles(row!["TEMPLATE NAME", "TASK NAME", "COMMENT", "COMPLETENESS"]);
392
393        // Populate table with template information
394        for template in templates {
395            table.add_row(row![
396                template.name,                         // Unique template identifier
397                template.task_name,                    // Default task title
398                template.comment,                      // Pre-configured description
399                format!("{}%", template.completeness)  // Default completion with % symbol
400            ]);
401        }
402
403        // Render the templates table to console
404        table.printstd();
405        Ok(())
406    }
407
408    /// Displays a formatted table of tags for task categorization and organization.
409    ///
410    /// This method provides a comprehensive view of all available tags that can
411    /// be applied to tasks for organization and filtering purposes. The table
412    /// shows both the functional and visual aspects of each tag.
413    ///
414    /// ## Tag Information
415    ///
416    /// The table displays key tag metadata:
417    /// - **ID**: Unique database identifier for programmatic reference
418    /// - **NAME**: Human-readable tag name used for categorization
419    /// - **COLOR**: Optional color coding for visual organization (if supported)
420    ///
421    /// ## Organizational Benefits
422    ///
423    /// Tags provide several organizational advantages:
424    /// - **Categorization**: Group related tasks by project, priority, or type
425    /// - **Filtering**: Quickly find tasks based on specific criteria
426    /// - **Visual Organization**: Color coding for rapid visual identification
427    /// - **Reporting**: Generate reports filtered by specific tag categories
428    ///
429    /// ## Color Display
430    ///
431    /// Colors are displayed as text values (hex codes, names, etc.) since
432    /// terminal color support varies. A dash (-) indicates no color assigned.
433    ///
434    /// # Arguments
435    ///
436    /// * `tags` - A slice of `Tag` structs to display in the table
437    ///
438    /// # Returns
439    ///
440    /// Returns `Ok(())` on successful table rendering, or an error if
441    /// display operations fail.
442    ///
443    /// # Examples
444    ///
445    /// ```rust,no_run
446    /// # fn f() -> anyhow::Result<()> {
447    /// use kasl::libs::view::View;
448    /// use kasl::db::tags::Tag;
449    ///
450    /// let tags: Vec<Tag> = vec![/* tag instances */];
451    /// View::tags(&tags)?;
452    /// # Ok(())
453    /// # }
454    /// ```
455    pub fn tags(tags: &[crate::db::tags::Tag]) -> Result<()> {
456        // Initialize table with appropriate formatting for tag data
457        let mut table = Table::new();
458        table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
459        table.set_titles(row!["ID", "NAME", "COLOR"]);
460
461        // Populate table with tag information
462        for tag in tags {
463            table.add_row(row![
464                tag.id.unwrap_or(0),                 // Database ID, showing 0 for new tags
465                tag.name,                            // Human-readable tag name
466                tag.color.as_deref().unwrap_or("-")  // Color value or dash if none
467            ]);
468        }
469
470        // Render the tags table to console
471        table.printstd();
472        Ok(())
473    }
474
475    /// Displays active Jira inbox items (pinned, score, then priority).
476    ///
477    /// SUMMARY is truncated to fit the terminal width (same approach as [`View::tasks`]).
478    pub fn jira_inbox(items: &[crate::db::jira_inbox::JiraInboxItem]) -> Result<()> {
479        let pin_width = "★".width().max(1);
480        let score_width = items
481            .iter()
482            .map(|i| i.sort_value.map(|v| format!("{}", v).width()).unwrap_or_else(|| "—".width()))
483            .max()
484            .unwrap_or(1)
485            .max("SCORE".width());
486        let priority_width = items
487            .iter()
488            .map(|i| i.priority.as_deref().unwrap_or("—").width())
489            .max()
490            .unwrap_or(1)
491            .max("PRIORITY".width());
492        let key_width = items.iter().map(|i| i.issue_key.width()).max().unwrap_or(1).max("KEY".width());
493        let status_width = items
494            .iter()
495            .map(|i| {
496                if i.status_name.is_empty() {
497                    i.status_id.as_deref().unwrap_or("—").width()
498                } else {
499                    i.status_name.width()
500                }
501            })
502            .max()
503            .unwrap_or(1)
504            .max("STATUS".width())
505            .min(18);
506
507        // "", SCORE, PRIORITY, KEY, STATUS, SUMMARY
508        let num_cols = 6;
509        let frame_overhead = 3 * num_cols + 1;
510        let fixed = pin_width + score_width + priority_width + key_width + status_width;
511        let summary_width = terminal_cols().saturating_sub(frame_overhead + fixed).max(12);
512
513        let mut table = Table::new();
514        table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
515        table.set_titles(row!["", "SCORE", "PRIORITY", "KEY", "STATUS", "SUMMARY"]);
516
517        for item in items {
518            let pin = if item.pinned { "★" } else { "" };
519            let score = item.sort_value.map(|v| format!("{}", v)).unwrap_or_else(|| "—".to_string());
520            let status_raw = if item.status_name.is_empty() {
521                item.status_id.as_deref().unwrap_or("—")
522            } else {
523                item.status_name.as_str()
524            };
525            table.add_row(row![
526                pin,
527                score,
528                item.priority.as_deref().unwrap_or("—"),
529                item.issue_key,
530                truncate_to_width(status_raw, status_width),
531                truncate_to_width(&item.summary, summary_width),
532            ]);
533        }
534
535        table.printstd();
536        Ok(())
537    }
538}