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};
40use crate::libs::messages::Message;
41use crate::libs::output;
42use crate::libs::pause::Pause;
43use crate::libs::report;
44use crate::msg_print;
45use anyhow::Result;
46use chrono::{Duration, NaiveDate, TimeDelta};
47use prettytable::{Cell, Row, Table, row};
48use std::collections::HashMap;
49use unicode_width::UnicodeWidthStr;
50
51/// A utility struct for rendering application data to the console.
52///
53/// Serves as a namespace for various table rendering functions. All methods are static,
54/// making it easy to call formatting functions without needing to instantiate the struct.
55pub struct View {}
56
57impl View {
58    /// Displays a formatted table of tasks with comprehensive metadata.
59    ///
60    /// Renders a detailed table showing task information including identification numbers,
61    /// names, completion status, comments, and associated tags.
62    pub fn tasks(tasks: &[Task]) -> Result<()> {
63        let show_task_id = tasks.iter().any(|t| t.task_id.is_some_and(|id| id != 0));
64        let show_comment = tasks.iter().any(|t| !t.comment.trim().is_empty());
65        let show_tags = tasks.iter().any(|t| !t.tags.is_empty());
66
67        let idx_width = tasks.len().to_string().width().max("#".width());
68        let id_width = tasks.iter().map(|t| t.id.unwrap_or(0).to_string().width()).max().unwrap_or(1).max("ID".width());
69        let task_id_width = if show_task_id {
70            tasks
71                .iter()
72                .map(|t| t.task_id.unwrap_or(0).to_string().width())
73                .max()
74                .unwrap_or(1)
75                .max("TASK ID".width())
76        } else {
77            0
78        };
79        let done_width = "DONE".width().max("100%".width());
80
81        // prettytable cell format: `| content |` → 3 chars overhead per column + 1 outer border
82        let mut num_cols = 4; // #, ID, NAME, DONE
83        if show_task_id {
84            num_cols += 1;
85        }
86        if show_comment {
87            num_cols += 1;
88        }
89        if show_tags {
90            num_cols += 1;
91        }
92
93        let mut fixed_content = idx_width + id_width + done_width;
94        if show_task_id {
95            fixed_content += task_id_width;
96        }
97
98        let frame_overhead = 3 * num_cols + 1;
99        let mut flexible = terminal_cols().saturating_sub(frame_overhead + fixed_content);
100
101        // Reserve a modest slice for optional text columns; NAME gets the rest.
102        let tags_width = if show_tags {
103            let width = (flexible / 5).clamp(8, 20);
104            flexible = flexible.saturating_sub(width);
105            width
106        } else {
107            0
108        };
109        let comment_width = if show_comment {
110            let width = (flexible / 3).clamp(12, 40);
111            flexible = flexible.saturating_sub(width);
112            width
113        } else {
114            0
115        };
116        let name_width = flexible.max(12);
117
118        let mut table = Table::new();
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        let 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(&output::fit(&task.name, name_width)));
140            if show_comment {
141                cells.push(Cell::new(&output::fit(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(&output::fit(&tags_str, tags_width)));
147            }
148            table.add_row(Row::new(cells));
149        }
150
151        output::print_table(titles, table);
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        let titles = row!["ID", "START", "END", "DURATION"];
163
164        // Add each work interval as a table row with formatted times
165        for (index, interval) in intervals.iter().enumerate() {
166            table.add_row(row![
167                index + 1,                           // Sequential numbering for easy reference
168                interval.start.format("%H:%M"),      // Start time in HH:MM format
169                interval.end.format("%H:%M"),        // End time in HH:MM format
170                format_duration(&interval.duration)  // Human-readable duration
171            ]);
172        }
173
174        // Add summary rows with total time and productivity metrics
175        table.add_empty_row(); // Visual separator before summary
176        table.add_row(row!["TOTAL", "", "", format_duration(filtered_duration)]);
177        table.add_row(row!["PRODUCTIVITY", "", "", format!("{:.1}%", productivity)]);
178
179        // Render the intervals table to console
180        output::print_table(titles, table);
181
182        // Display associated tasks if any were completed during the day
183        if !tasks.is_empty() {
184            msg_print!(Message::TasksHeader, true);
185            Self::tasks(tasks)?;
186        }
187
188        Ok(())
189    }
190
191    /// Displays a monthly summary of working hours with daily breakdowns.
192    ///
193    /// # Examples
194    ///
195    /// ```rust,no_run
196    /// # fn f() -> anyhow::Result<()> {
197    /// use kasl::libs::view::View;
198    /// use std::collections::HashMap;
199    ///
200    /// let daily_map = HashMap::new();
201    /// let total_hours = String::new();
202    /// let average_hours = String::new();
203    ///
204    /// let summary_data = (daily_map, total_hours, average_hours);
205    /// View::sum(&summary_data)?;
206    /// # Ok(())
207    /// # }
208    /// ```
209    pub fn sum((daily_durations, total_duration, average_duration): &(HashMap<NaiveDate, (String, String)>, String, String)) -> Result<()> {
210        // Initialize table with appropriate formatting for summary data
211        let mut table: Table = Table::new();
212        let titles = row!["DATE", "HOURS", "PRODUCTIVITY"];
213
214        // Sort dates chronologically for logical display order
215        let mut sorted_dates: Vec<&NaiveDate> = daily_durations.keys().collect();
216        sorted_dates.sort();
217
218        // Add each day's data as a table row
219        for date in sorted_dates {
220            if let Some((duration, productivity)) = daily_durations.get(date) {
221                table.add_row(row![
222                    date.format("%Y-%m-%d"), // ISO date format for consistency
223                    duration,                // Formatted duration string
224                    productivity             // Productivity percentage or status
225                ]);
226            }
227        }
228
229        // Add summary statistics with visual separation
230        table.add_empty_row(); // Visual separator before totals
231        table.add_row(row!["TOTAL", total_duration, ""]);
232        table.add_row(row!["AVERAGE", average_duration, ""]);
233
234        // Render the summary table to console
235        output::print_table(titles, table);
236        Ok(())
237    }
238
239    /// Displays a table of pauses for a given day with total pause time.
240    pub fn pauses(pauses: &[Pause], total_pause_time: Duration) -> Result<()> {
241        let mut table = Table::new();
242        let titles = row!["ID", "START", "END", "DURATION"];
243
244        for (i, b) in pauses.iter().enumerate() {
245            table.add_row(row![
246                i + 1,
247                b.start.format("%H:%M"),
248                b.end.map(|t| t.format("%H:%M").to_string()).unwrap_or_else(|| "-".to_string()),
249                b.duration
250                    .map(|duration: TimeDelta| format_duration(&duration))
251                    .unwrap_or_else(|| "--:--".to_string())
252            ]);
253        }
254
255        // Add total row
256        if !pauses.is_empty() {
257            table.add_empty_row();
258            table.add_row(row!["TOTAL", "", "", format_duration(&total_pause_time)]);
259        }
260
261        output::print_table(titles, table);
262        Ok(())
263    }
264
265    /// Displays a formatted table of task templates for reusable task creation.
266    ///
267    /// # Examples
268    ///
269    /// ```rust,no_run
270    /// # fn f() -> anyhow::Result<()> {
271    /// use kasl::libs::view::View;
272    /// use kasl::db::templates::TaskTemplate;
273    ///
274    /// let templates: Vec<TaskTemplate> = vec![/* template instances */];
275    /// View::templates(&templates)?;
276    /// # Ok(())
277    /// # }
278    /// ```
279    pub fn templates(templates: &[TaskTemplate]) -> Result<()> {
280        // Initialize table with clean formatting for template data
281        let mut table = Table::new();
282        let titles = row!["TEMPLATE NAME", "TASK NAME", "COMMENT", "COMPLETENESS"];
283
284        // Populate table with template information
285        for template in templates {
286            table.add_row(row![
287                template.name,                         // Unique template identifier
288                template.task_name,                    // Default task title
289                template.comment,                      // Pre-configured description
290                format!("{}%", template.completeness)  // Default completion with % symbol
291            ]);
292        }
293
294        // Render the templates table to console
295        output::print_table(titles, table);
296        Ok(())
297    }
298
299    /// Displays a formatted table of tags for task categorization and organization.
300    ///
301    /// # Examples
302    ///
303    /// ```rust,no_run
304    /// # fn f() -> anyhow::Result<()> {
305    /// use kasl::libs::view::View;
306    /// use kasl::db::tags::Tag;
307    ///
308    /// let tags: Vec<Tag> = vec![/* tag instances */];
309    /// View::tags(&tags)?;
310    /// # Ok(())
311    /// # }
312    /// ```
313    pub fn tags(tags: &[crate::db::tags::Tag]) -> Result<()> {
314        // Initialize table with appropriate formatting for tag data
315        let mut table = Table::new();
316        let titles = row!["ID", "NAME", "COLOR"];
317
318        // Populate table with tag information
319        for tag in tags {
320            table.add_row(row![
321                tag.id.unwrap_or(0),                 // Database ID, showing 0 for new tags
322                tag.name,                            // Human-readable tag name
323                tag.color.as_deref().unwrap_or("-")  // Color value or dash if none
324            ]);
325        }
326
327        // Render the tags table to console
328        output::print_table(titles, table);
329        Ok(())
330    }
331
332    /// Displays active Jira inbox items (pinned, score, then priority).
333    ///
334    /// SUMMARY is truncated to fit the terminal width (same approach as [`View::tasks`]).
335    /// The CHANGE column shows freshness badges: `NEW`, `gone`, or the latest
336    /// visible change such as `status→In Progress`.
337    pub fn jira_inbox(items: &[crate::db::jira_inbox::JiraInboxItem]) -> Result<()> {
338        let now = chrono::Local::now().naive_local();
339        let badges: Vec<String> = items.iter().map(|i| i.badge(now).unwrap_or_default()).collect();
340        let pin_width = "★".width().max(1);
341        let change_width = badges.iter().map(|b| b.width()).max().unwrap_or(1).max("CHANGE".width()).min(24);
342        let score_width = items
343            .iter()
344            .map(|i| i.sort_value.map(|v| format!("{}", v).width()).unwrap_or_else(|| "—".width()))
345            .max()
346            .unwrap_or(1)
347            .max("SCORE".width());
348        let priority_width = items
349            .iter()
350            .map(|i| i.priority.as_deref().unwrap_or("—").width())
351            .max()
352            .unwrap_or(1)
353            .max("PRIORITY".width());
354        let key_width = items.iter().map(|i| i.issue_key.width()).max().unwrap_or(1).max("KEY".width());
355        let status_width = items
356            .iter()
357            .map(|i| {
358                if i.status_name.is_empty() {
359                    i.status_id.as_deref().unwrap_or("—").width()
360                } else {
361                    i.status_name.width()
362                }
363            })
364            .max()
365            .unwrap_or(1)
366            .max("STATUS".width())
367            .min(18);
368
369        // "", CHANGE, SCORE, PRIORITY, KEY, STATUS, SUMMARY
370        let num_cols = 7;
371        let frame_overhead = 3 * num_cols + 1;
372        let fixed = pin_width + change_width + score_width + priority_width + key_width + status_width;
373        let summary_width = terminal_cols().saturating_sub(frame_overhead + fixed).max(12);
374
375        let mut table = Table::new();
376        let titles = row!["★", "CHANGE", "SCORE", "PRIORITY", "KEY", "STATUS", "SUMMARY"];
377
378        for (item, badge) in items.iter().zip(&badges) {
379            let pin = if item.pinned { "★" } else { "" };
380            let score = item.sort_value.map(|v| format!("{}", v)).unwrap_or_else(|| "—".to_string());
381            let status_raw = if item.status_name.is_empty() {
382                item.status_id.as_deref().unwrap_or("—")
383            } else {
384                item.status_name.as_str()
385            };
386            table.add_row(row![
387                pin,
388                output::fit(badge, change_width),
389                score,
390                item.priority.as_deref().unwrap_or("—"),
391                item.issue_key,
392                output::fit(status_raw, status_width),
393                output::fit(&item.summary, summary_width),
394            ]);
395        }
396
397        output::print_table(titles, table);
398        Ok(())
399    }
400
401    /// One issue in full: the fields a table row has no room for.
402    pub fn jira_inbox_issue(item: &crate::db::jira_inbox::JiraInboxItem) -> Result<()> {
403        let now = chrono::Local::now().naive_local();
404        let mut table = Table::new();
405        let titles = row!["FIELD", "VALUE"];
406
407        let mut add = |field: &str, value: String| {
408            table.add_row(row![field, value]);
409        };
410        add("key", item.issue_key.clone());
411        add("summary", item.summary.clone());
412        add(
413            "status",
414            if item.status_name.is_empty() {
415                item.status_id.clone().unwrap_or_else(|| "—".to_string())
416            } else {
417                item.status_name.clone()
418            },
419        );
420        add("priority", item.priority.clone().unwrap_or_else(|| "—".to_string()));
421        add("score", item.sort_value.map(|v| format!("{v}")).unwrap_or_else(|| "—".to_string()));
422        add("type", item.issue_type.clone().unwrap_or_else(|| "—".to_string()));
423        add("project", item.project.clone().unwrap_or_else(|| "—".to_string()));
424        if let Some(rule) = &item.ruled_by {
425            add(if item.dismissed { "dismissed by" } else { "pinned by" }, format!("rule '{rule}'"));
426        }
427        if let Some(badge) = item.badge(now) {
428            add("badge", badge);
429        }
430        add("first seen", item.first_seen.format("%Y-%m-%d %H:%M").to_string());
431        add("last seen", item.last_seen.format("%Y-%m-%d %H:%M").to_string());
432        if let Some(at) = item.changed_at {
433            add("changed", at.format("%Y-%m-%d %H:%M").to_string());
434        }
435        if let Some(at) = item.taken_at {
436            add("taken", at.format("%Y-%m-%d %H:%M").to_string());
437        }
438        if let Some(at) = item.snoozed_until {
439            add("snoozed until", at.format("%Y-%m-%d %H:%M").to_string());
440        }
441        if let Some(at) = item.gone_at {
442            add("gone", at.format("%Y-%m-%d %H:%M").to_string());
443            match (item.left_reason, item.resolution.as_deref()) {
444                (Some(crate::db::jira_inbox::LeftReason::Resolved), Some(resolution)) => add("left", format!("resolved in Jira ({resolution})")),
445                (Some(crate::db::jira_inbox::LeftReason::Resolved), None) => add("left", "resolved in Jira".to_string()),
446                (Some(crate::db::jira_inbox::LeftReason::Moved), _) => add("left", "unresolved - reassigned or out of reach".to_string()),
447                (None, _) => {}
448            }
449        }
450        add("url", item.url.clone());
451
452        output::print_table(titles, table);
453        Ok(())
454    }
455
456    /// The inbox rules, each with what it matches now.
457    pub fn inbox_rules(rows: &[crate::libs::inbox_rules::RuleStanding]) -> Result<()> {
458        let names: Vec<String> = rows.iter().map(|r| r.name.clone().unwrap_or_else(|| "—".to_string())).collect();
459        let number_width = rows.iter().map(|r| r.number.to_string().width()).max().unwrap_or(1).max("#".width());
460        let name_width = names.iter().map(|n| n.width()).max().unwrap_or(1).max("NAME".width()).min(20);
461        let when_width = rows.iter().map(|r| r.when.width()).max().unwrap_or(1).max("WHEN".width()).min(32);
462        let then_width = rows.iter().map(|r| r.then.width()).max().unwrap_or(1).max("THEN".width()).min(24);
463
464        // #, NAME, WHEN, THEN, NOW
465        let frame_overhead = 3 * 5 + 1;
466        let now_width = terminal_cols()
467            .saturating_sub(frame_overhead + number_width + name_width + when_width + then_width)
468            .max(20);
469
470        let mut table = Table::new();
471        let titles = row!["#", "NAME", "WHEN", "THEN", "NOW"];
472        for (row, name) in rows.iter().zip(&names) {
473            // Wrapped, not truncated: the conditions are what the rule is,
474            // and a cut-off one reads as a different rule.
475            table.add_row(row![
476                row.number,
477                output::fit(name, name_width),
478                output::wrap(&row.when, when_width),
479                output::wrap(&row.then, then_width),
480                output::wrap(&row.now, now_width),
481            ]);
482        }
483        output::print_table(titles, table);
484        Ok(())
485    }
486
487    /// Where an issue's place in the list comes from.
488    pub fn jira_inbox_why(reasons: &[crate::libs::inbox_filter::Reason]) -> Result<()> {
489        let what_width = reasons.iter().map(|r| r.what.width()).max().unwrap_or(1).max("WHAT".width());
490        let value_width = reasons.iter().map(|r| r.value.width()).max().unwrap_or(1).max("VALUE".width()).min(24);
491
492        // WHAT, VALUE, WHY
493        let frame_overhead = 3 * 3 + 1;
494        let why_width = terminal_cols().saturating_sub(frame_overhead + what_width + value_width).max(20);
495
496        let mut table = Table::new();
497        let titles = row!["WHAT", "VALUE", "WHY"];
498        for reason in reasons {
499            // Wrapped, not truncated: this column exists to be read, and a
500            // cut-off explanation explains nothing.
501            table.add_row(row![
502                reason.what,
503                output::fit(&reason.value, value_width),
504                output::wrap(&reason.because, why_width),
505            ]);
506        }
507        output::print_table(titles, table);
508        Ok(())
509    }
510}