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//! ## Usage
7//!
8//! ```rust,no_run
9//! # fn f() -> anyhow::Result<()> {
10//! use kasl::libs::view::View;
11//! use kasl::libs::task::Task;
12//! use kasl::db::workdays::Workday;
13//! use kasl::libs::report::WorkInterval;
14//! use chrono::{Local, TimeDelta};
15//! use std::collections::HashMap;
16//!
17//! let tasks: Vec<Task> = vec![];
18//! let workday = Workday {
19//!     id: 1,
20//!     date: Local::now().date_naive(),
21//!     start: Local::now().naive_local(),
22//!     end: None,
23//! };
24//! let intervals: Vec<WorkInterval> = vec![];
25//! let filtered_duration = TimeDelta::zero();
26//! let productivity = 0.0_f64;
27//! let summary_data = (HashMap::new(), String::new(), String::new());
28//!
29//! View::tasks(&tasks)?;
30//! View::report(&workday, &intervals, &filtered_duration, &productivity, &tasks)?;
31//! View::sum(&summary_data)?;
32//! # Ok(())
33//! # }
34//! ```
35
36use super::task::Task;
37use crate::db::templates::TaskTemplate;
38use crate::db::workdays::Workday;
39use crate::libs::formatter::{format_duration, terminal_cols, truncate_to_width};
40use crate::libs::messages::Message;
41use crate::libs::pause::Pause;
42use crate::libs::report;
43use crate::msg_print;
44use anyhow::Result;
45use chrono::{Duration, NaiveDate, TimeDelta};
46use prettytable::{Cell, Row, Table, format, row};
47use std::collections::HashMap;
48use unicode_width::UnicodeWidthStr;
49
50/// A utility struct for rendering application data to the console.
51///
52/// Serves as a namespace for various table rendering functions. All methods are static,
53/// making it easy to call formatting functions without needing to instantiate the struct.
54pub struct View {}
55
56impl View {
57    /// Displays a formatted table of tasks with comprehensive metadata.
58    ///
59    /// Renders a detailed table showing task information including identification numbers,
60    /// names, completion status, comments, and associated tags.
61    pub fn tasks(tasks: &[Task]) -> Result<()> {
62        let show_task_id = tasks.iter().any(|t| t.task_id.is_some_and(|id| id != 0));
63        let show_comment = tasks.iter().any(|t| !t.comment.trim().is_empty());
64        let show_tags = tasks.iter().any(|t| !t.tags.is_empty());
65
66        let idx_width = tasks.len().to_string().width().max("#".width());
67        let id_width = tasks.iter().map(|t| t.id.unwrap_or(0).to_string().width()).max().unwrap_or(1).max("ID".width());
68        let task_id_width = if show_task_id {
69            tasks
70                .iter()
71                .map(|t| t.task_id.unwrap_or(0).to_string().width())
72                .max()
73                .unwrap_or(1)
74                .max("TASK ID".width())
75        } else {
76            0
77        };
78        let done_width = "DONE".width().max("100%".width());
79
80        // prettytable cell format: `| content |` → 3 chars overhead per column + 1 outer border
81        let mut num_cols = 4; // #, ID, NAME, DONE
82        if show_task_id {
83            num_cols += 1;
84        }
85        if show_comment {
86            num_cols += 1;
87        }
88        if show_tags {
89            num_cols += 1;
90        }
91
92        let mut fixed_content = idx_width + id_width + done_width;
93        if show_task_id {
94            fixed_content += task_id_width;
95        }
96
97        let frame_overhead = 3 * num_cols + 1;
98        let mut flexible = terminal_cols().saturating_sub(frame_overhead + fixed_content);
99
100        // Reserve a modest slice for optional text columns; NAME gets the rest.
101        let tags_width = if show_tags {
102            let width = (flexible / 5).clamp(8, 20);
103            flexible = flexible.saturating_sub(width);
104            width
105        } else {
106            0
107        };
108        let comment_width = if show_comment {
109            let width = (flexible / 3).clamp(12, 40);
110            flexible = flexible.saturating_sub(width);
111            width
112        } else {
113            0
114        };
115        let name_width = flexible.max(12);
116
117        let mut table = Table::new();
118        table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
119
120        let mut titles = vec![Cell::new("#"), Cell::new("ID")];
121        if show_task_id {
122            titles.push(Cell::new("TASK ID"));
123        }
124        titles.push(Cell::new("NAME"));
125        if show_comment {
126            titles.push(Cell::new("COMMENT"));
127        }
128        titles.push(Cell::new("DONE"));
129        if show_tags {
130            titles.push(Cell::new("TAGS"));
131        }
132        table.set_titles(Row::new(titles));
133
134        for (index, task) in tasks.iter().enumerate() {
135            let mut cells = vec![Cell::new(&(index + 1).to_string()), Cell::new(&task.id.unwrap_or(0).to_string())];
136            if show_task_id {
137                cells.push(Cell::new(&task.task_id.unwrap_or(0).to_string()));
138            }
139            cells.push(Cell::new(&truncate_to_width(&task.name, name_width)));
140            if show_comment {
141                cells.push(Cell::new(&truncate_to_width(task.comment.trim(), comment_width)));
142            }
143            cells.push(Cell::new(&format!("{}%", task.completeness.unwrap_or(100))));
144            if show_tags {
145                let tags_str = task.tags.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ");
146                cells.push(Cell::new(&truncate_to_width(&tags_str, tags_width)));
147            }
148            table.add_row(Row::new(cells));
149        }
150
151        table.printstd();
152        Ok(())
153    }
154
155    /// Displays a formatted daily work report using pre-calculated intervals.
156    pub fn report(workday: &Workday, intervals: &[report::WorkInterval], filtered_duration: &TimeDelta, productivity: &f64, tasks: &[Task]) -> Result<()> {
157        // Display formatted report header with readable date
158        msg_print!(Message::ReportHeader(workday.date.format("%B %-d, %Y").to_string()), true);
159
160        // Create and populate the work intervals table
161        let mut table = Table::new();
162        table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
163        table.set_titles(row!["ID", "START", "END", "DURATION"]);
164
165        // Add each work interval as a table row with formatted times
166        for (index, interval) in intervals.iter().enumerate() {
167            table.add_row(row![
168                index + 1,                           // Sequential numbering for easy reference
169                interval.start.format("%H:%M"),      // Start time in HH:MM format
170                interval.end.format("%H:%M"),        // End time in HH:MM format
171                format_duration(&interval.duration)  // Human-readable duration
172            ]);
173        }
174
175        // Add summary rows with total time and productivity metrics
176        table.add_empty_row(); // Visual separator before summary
177        table.add_row(row!["TOTAL", "", "", format_duration(filtered_duration)]);
178        table.add_row(row!["PRODUCTIVITY", "", "", format!("{:.1}%", productivity)]);
179
180        // Render the intervals table to console
181        table.printstd();
182
183        // Display associated tasks if any were completed during the day
184        if !tasks.is_empty() {
185            msg_print!(Message::TasksHeader, true);
186            Self::tasks(tasks)?;
187        }
188
189        Ok(())
190    }
191
192    /// Displays a monthly summary of working hours with daily breakdowns.
193    ///
194    /// # Examples
195    ///
196    /// ```rust,no_run
197    /// # fn f() -> anyhow::Result<()> {
198    /// use kasl::libs::view::View;
199    /// use std::collections::HashMap;
200    ///
201    /// let daily_map = HashMap::new();
202    /// let total_hours = String::new();
203    /// let average_hours = String::new();
204    ///
205    /// let summary_data = (daily_map, total_hours, average_hours);
206    /// View::sum(&summary_data)?;
207    /// # Ok(())
208    /// # }
209    /// ```
210    pub fn sum((daily_durations, total_duration, average_duration): &(HashMap<NaiveDate, (String, String)>, String, String)) -> Result<()> {
211        // Initialize table with appropriate formatting for summary data
212        let mut table: Table = Table::new();
213        table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
214        table.set_titles(row!["DATE", "HOURS", "PRODUCTIVITY"]);
215
216        // Sort dates chronologically for logical display order
217        let mut sorted_dates: Vec<&NaiveDate> = daily_durations.keys().collect();
218        sorted_dates.sort();
219
220        // Add each day's data as a table row
221        for date in sorted_dates {
222            if let Some((duration, productivity)) = daily_durations.get(date) {
223                table.add_row(row![
224                    date.format("%Y-%m-%d"), // ISO date format for consistency
225                    duration,                // Formatted duration string
226                    productivity             // Productivity percentage or status
227                ]);
228            }
229        }
230
231        // Add summary statistics with visual separation
232        table.add_empty_row(); // Visual separator before totals
233        table.add_row(row!["TOTAL", total_duration, ""]);
234        table.add_row(row!["AVERAGE", average_duration, ""]);
235
236        // Render the summary table to console
237        table.printstd();
238        Ok(())
239    }
240
241    /// Displays a table of pauses for a given day with total pause time.
242    pub fn pauses(pauses: &[Pause], total_pause_time: Duration) -> Result<()> {
243        let mut table = Table::new();
244        table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
245        table.set_titles(row!["ID", "START", "END", "DURATION"]);
246
247        for (i, b) in pauses.iter().enumerate() {
248            table.add_row(row![
249                i + 1,
250                b.start.format("%H:%M"),
251                b.end.map(|t| t.format("%H:%M").to_string()).unwrap_or_else(|| "-".to_string()),
252                b.duration
253                    .map(|duration: TimeDelta| format_duration(&duration))
254                    .unwrap_or_else(|| "--:--".to_string())
255            ]);
256        }
257
258        // Add total row
259        if !pauses.is_empty() {
260            table.add_empty_row();
261            table.add_row(row!["TOTAL", "", "", format_duration(&total_pause_time)]);
262        }
263
264        table.printstd();
265        Ok(())
266    }
267
268    /// Displays a formatted table of task templates for reusable task creation.
269    ///
270    /// # Examples
271    ///
272    /// ```rust,no_run
273    /// # fn f() -> anyhow::Result<()> {
274    /// use kasl::libs::view::View;
275    /// use kasl::db::templates::TaskTemplate;
276    ///
277    /// let templates: Vec<TaskTemplate> = vec![/* template instances */];
278    /// View::templates(&templates)?;
279    /// # Ok(())
280    /// # }
281    /// ```
282    pub fn templates(templates: &[TaskTemplate]) -> Result<()> {
283        // Initialize table with clean formatting for template data
284        let mut table = Table::new();
285        table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
286        table.set_titles(row!["TEMPLATE NAME", "TASK NAME", "COMMENT", "COMPLETENESS"]);
287
288        // Populate table with template information
289        for template in templates {
290            table.add_row(row![
291                template.name,                         // Unique template identifier
292                template.task_name,                    // Default task title
293                template.comment,                      // Pre-configured description
294                format!("{}%", template.completeness)  // Default completion with % symbol
295            ]);
296        }
297
298        // Render the templates table to console
299        table.printstd();
300        Ok(())
301    }
302
303    /// Displays a formatted table of tags for task categorization and organization.
304    ///
305    /// # Examples
306    ///
307    /// ```rust,no_run
308    /// # fn f() -> anyhow::Result<()> {
309    /// use kasl::libs::view::View;
310    /// use kasl::db::tags::Tag;
311    ///
312    /// let tags: Vec<Tag> = vec![/* tag instances */];
313    /// View::tags(&tags)?;
314    /// # Ok(())
315    /// # }
316    /// ```
317    pub fn tags(tags: &[crate::db::tags::Tag]) -> Result<()> {
318        // Initialize table with appropriate formatting for tag data
319        let mut table = Table::new();
320        table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
321        table.set_titles(row!["ID", "NAME", "COLOR"]);
322
323        // Populate table with tag information
324        for tag in tags {
325            table.add_row(row![
326                tag.id.unwrap_or(0),                 // Database ID, showing 0 for new tags
327                tag.name,                            // Human-readable tag name
328                tag.color.as_deref().unwrap_or("-")  // Color value or dash if none
329            ]);
330        }
331
332        // Render the tags table to console
333        table.printstd();
334        Ok(())
335    }
336
337    /// Displays active Jira inbox items (pinned, score, then priority).
338    ///
339    /// SUMMARY is truncated to fit the terminal width (same approach as [`View::tasks`]).
340    /// The CHANGE column shows freshness badges: `NEW`, `gone`, or the latest
341    /// visible change such as `status→In Progress`.
342    pub fn jira_inbox(items: &[crate::db::jira_inbox::JiraInboxItem]) -> Result<()> {
343        let now = chrono::Local::now().naive_local();
344        let badges: Vec<String> = items.iter().map(|i| i.badge(now).unwrap_or_default()).collect();
345        let pin_width = "★".width().max(1);
346        let change_width = badges.iter().map(|b| b.width()).max().unwrap_or(1).max("CHANGE".width()).min(24);
347        let score_width = items
348            .iter()
349            .map(|i| i.sort_value.map(|v| format!("{}", v).width()).unwrap_or_else(|| "—".width()))
350            .max()
351            .unwrap_or(1)
352            .max("SCORE".width());
353        let priority_width = items
354            .iter()
355            .map(|i| i.priority.as_deref().unwrap_or("—").width())
356            .max()
357            .unwrap_or(1)
358            .max("PRIORITY".width());
359        let key_width = items.iter().map(|i| i.issue_key.width()).max().unwrap_or(1).max("KEY".width());
360        let status_width = items
361            .iter()
362            .map(|i| {
363                if i.status_name.is_empty() {
364                    i.status_id.as_deref().unwrap_or("—").width()
365                } else {
366                    i.status_name.width()
367                }
368            })
369            .max()
370            .unwrap_or(1)
371            .max("STATUS".width())
372            .min(18);
373
374        // "", CHANGE, SCORE, PRIORITY, KEY, STATUS, SUMMARY
375        let num_cols = 7;
376        let frame_overhead = 3 * num_cols + 1;
377        let fixed = pin_width + change_width + score_width + priority_width + key_width + status_width;
378        let summary_width = terminal_cols().saturating_sub(frame_overhead + fixed).max(12);
379
380        let mut table = Table::new();
381        table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
382        table.set_titles(row!["", "CHANGE", "SCORE", "PRIORITY", "KEY", "STATUS", "SUMMARY"]);
383
384        for (item, badge) in items.iter().zip(&badges) {
385            let pin = if item.pinned { "★" } else { "" };
386            let score = item.sort_value.map(|v| format!("{}", v)).unwrap_or_else(|| "—".to_string());
387            let status_raw = if item.status_name.is_empty() {
388                item.status_id.as_deref().unwrap_or("—")
389            } else {
390                item.status_name.as_str()
391            };
392            table.add_row(row![
393                pin,
394                truncate_to_width(badge, change_width),
395                score,
396                item.priority.as_deref().unwrap_or("—"),
397                item.issue_key,
398                truncate_to_width(status_raw, status_width),
399                truncate_to_width(&item.summary, summary_width),
400            ]);
401        }
402
403        table.printstd();
404        Ok(())
405    }
406}