1use 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
51pub struct View {}
56
57impl View {
58 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 let mut num_cols = 4; 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 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 pub fn report(workday: &Workday, intervals: &[report::WorkInterval], filtered_duration: &TimeDelta, productivity: &f64, tasks: &[Task]) -> Result<()> {
157 msg_print!(Message::ReportHeader(workday.date.format("%B %-d, %Y").to_string()), true);
159
160 let mut table = Table::new();
162 let titles = row!["ID", "START", "END", "DURATION"];
163
164 for (index, interval) in intervals.iter().enumerate() {
166 table.add_row(row![
167 index + 1, interval.start.format("%H:%M"), interval.end.format("%H:%M"), format_duration(&interval.duration) ]);
172 }
173
174 table.add_empty_row(); table.add_row(row!["TOTAL", "", "", format_duration(filtered_duration)]);
177 table.add_row(row!["PRODUCTIVITY", "", "", format!("{:.1}%", productivity)]);
178
179 output::print_table(titles, table);
181
182 if !tasks.is_empty() {
184 msg_print!(Message::TasksHeader, true);
185 Self::tasks(tasks)?;
186 }
187
188 Ok(())
189 }
190
191 pub fn sum((daily_durations, total_duration, average_duration): &(HashMap<NaiveDate, (String, String)>, String, String)) -> Result<()> {
210 let mut table: Table = Table::new();
212 let titles = row!["DATE", "HOURS", "PRODUCTIVITY"];
213
214 let mut sorted_dates: Vec<&NaiveDate> = daily_durations.keys().collect();
216 sorted_dates.sort();
217
218 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"), duration, productivity ]);
226 }
227 }
228
229 table.add_empty_row(); table.add_row(row!["TOTAL", total_duration, ""]);
232 table.add_row(row!["AVERAGE", average_duration, ""]);
233
234 output::print_table(titles, table);
236 Ok(())
237 }
238
239 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 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 pub fn templates(templates: &[TaskTemplate]) -> Result<()> {
280 let mut table = Table::new();
282 let titles = row!["TEMPLATE NAME", "TASK NAME", "COMMENT", "COMPLETENESS"];
283
284 for template in templates {
286 table.add_row(row![
287 template.name, template.task_name, template.comment, format!("{}%", template.completeness) ]);
292 }
293
294 output::print_table(titles, table);
296 Ok(())
297 }
298
299 pub fn tags(tags: &[crate::db::tags::Tag]) -> Result<()> {
314 let mut table = Table::new();
316 let titles = row!["ID", "NAME", "COLOR"];
317
318 for tag in tags {
320 table.add_row(row![
321 tag.id.unwrap_or(0), tag.name, tag.color.as_deref().unwrap_or("-") ]);
325 }
326
327 output::print_table(titles, table);
329 Ok(())
330 }
331
332 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 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 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 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 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 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 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 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 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}