Skip to main content

ytcli/render/
board.rs

1//! Boards and their sprints.
2
3use std::fmt::Write as _;
4
5use crate::api::{Board, Sprint};
6use crate::render::Context;
7use crate::render::style::Palette;
8use crate::render::table::{Column, render, tally};
9
10/// One line per board.
11///
12/// The column count rather than the columns: a listing answers "which board",
13/// and the columns themselves are what `board get` is for.
14#[must_use]
15pub fn boards(boards: &[Board], ctx: &Context) -> String {
16    let columns = [
17        Column::whole("ID", 8, Palette::key()),
18        Column::new("NAME", 36, anstyle::Style::new()),
19        Column::whole("COLUMNS", 8, anstyle::Style::new()),
20        Column::new("ESTIMATE", 14, Palette::label()),
21    ];
22    let rows: Vec<Vec<String>> = boards
23        .iter()
24        .map(|board| {
25            vec![
26                board.id.clone(),
27                board.name.clone(),
28                board.columns.len().to_string(),
29                board.estimate_by.as_deref().unwrap_or("-").to_owned(),
30            ]
31        })
32        .collect();
33
34    let mut out = render(&columns, &rows, ctx);
35    out.push_str(&tally(boards.len(), Some(boards.len() as u64), None, ctx));
36    out
37}
38
39/// One board, with its columns in the order it arranges work by.
40#[must_use]
41pub fn board(board: &Board, ctx: &Context) -> String {
42    let mut out = String::with_capacity(240);
43    let paint = ctx.painter();
44    let label = |text: &str| paint.paint(text, Palette::label());
45
46    let _ = writeln!(
47        out,
48        "{}  {}",
49        paint.paint(&board.id, Palette::key()),
50        board.name
51    );
52    let _ = writeln!(
53        out,
54        "{} {}   {} {}",
55        label("estimate:"),
56        board.estimate_by.as_deref().unwrap_or("-"),
57        label("owner:"),
58        board.owner.as_deref().unwrap_or("-"),
59    );
60    let _ = writeln!(
61        out,
62        "{} {}",
63        label("columns:"),
64        if board.columns.is_empty() {
65            "-".to_owned()
66        } else {
67            board.columns.join(" → ")
68        }
69    );
70
71    out
72}
73
74/// The glyph for a sprint's state.
75///
76/// Three states and three shapes, in a terminal only: a filled circle is
77/// running, a half-filled one is planned, an empty one is neither. A pipe keeps
78/// the word Tracker gave, because that is what a caller filters on.
79fn state(status: Option<&str>, ctx: &Context) -> String {
80    let word = status.unwrap_or("-");
81    if !ctx.is_human() {
82        return word.to_owned();
83    }
84
85    let (glyph, style) = match status {
86        Some("in_progress") => ("\u{25cf}", Palette::ok()),
87        Some("draft" | "planned") => ("\u{25d0}", Palette::warn()),
88        _ => ("\u{25cb}", Palette::label()),
89    };
90    format!("{} {word}", ctx.painter().paint(glyph, style))
91}
92
93/// One sprint, and how far through it is.
94///
95/// Two ratios, because a sprint that is four days from its end with half its
96/// issues open is a different situation from one that has just started, and a
97/// list of dates makes the reader do that arithmetic themselves.
98///
99/// `counts` is `(resolved, total)`, and absent when the issues were not asked
100/// for: two counts are two requests, and a caller who only wanted the dates
101/// should not pay for them.
102#[must_use]
103pub fn sprint(sprint: &Sprint, counts: Option<(u64, u64)>, today: &str, ctx: &Context) -> String {
104    let mut out = String::with_capacity(240);
105    let paint = ctx.painter();
106    let label = |text: &str| paint.paint(text, Palette::label());
107
108    let _ = writeln!(
109        out,
110        "{}  {}",
111        paint.paint(&sprint.id, Palette::key()),
112        sprint.name
113    );
114    let _ = writeln!(
115        out,
116        "{} {}   {} {}",
117        label("status:"),
118        state(sprint.status.as_deref(), ctx),
119        label("board:"),
120        sprint.board.as_deref().unwrap_or("-"),
121    );
122
123    let start = sprint.start.as_deref().unwrap_or("-");
124    let end = sprint.end.as_deref().unwrap_or("-");
125    let _ = write!(out, "{} {start} \u{2192} {end}", label("dates:"));
126    if let Some((elapsed, length)) = days(sprint.start.as_deref(), sprint.end.as_deref(), today) {
127        let _ = write!(
128            out,
129            "   {} days",
130            crate::render::bar::ratio(elapsed, length, ctx)
131        );
132    }
133    let _ = writeln!(out);
134
135    if let Some((resolved, total)) = counts {
136        let _ = writeln!(
137            out,
138            "{} {} resolved",
139            label("issues:"),
140            crate::render::bar::ratio(resolved, total, ctx)
141        );
142    }
143
144    out
145}
146
147/// Days elapsed of days planned, from the dates as Tracker writes them.
148///
149/// Both ends are counted, so a one-day sprint is one day long rather than zero.
150/// A sprint that has run over its end date reports more elapsed than planned,
151/// and the bar caps itself; pretending otherwise would hide the thing worth
152/// noticing.
153fn days(start: Option<&str>, end: Option<&str>, today: &str) -> Option<(u64, u64)> {
154    let start: jiff::civil::Date = start?.parse().ok()?;
155    let end: jiff::civil::Date = end?.parse().ok()?;
156    let today: jiff::civil::Date = today.parse().ok()?;
157
158    let length = (end - start).get_days().checked_add(1)?;
159    let elapsed = (today - start).get_days().checked_add(1)?;
160    u64::try_from(length)
161        .ok()
162        .map(|length| (u64::try_from(elapsed).unwrap_or(0), length))
163}
164
165/// Every sprint in the organisation, with the board each belongs to.
166///
167/// The board column is the difference from [`sprints`]: two boards each having
168/// a "Sprint 1" is normal, and without it the listing would be a set of names
169/// nobody could act on.
170#[must_use]
171pub fn all_sprints(sprints: &[Sprint], ctx: &Context) -> String {
172    let columns = [
173        Column::whole("ID", 8, Palette::key()),
174        Column::new("NAME", 26, anstyle::Style::new()),
175        Column::new("BOARD", 20, Palette::label()),
176        Column::new("STATUS", 12, anstyle::Style::new()),
177        Column::whole("START", 12, anstyle::Style::new()),
178        Column::whole("END", 12, anstyle::Style::new()),
179    ];
180    let rows: Vec<Vec<String>> = sprints
181        .iter()
182        .map(|sprint| {
183            vec![
184                sprint.id.clone(),
185                sprint.name.clone(),
186                sprint.board.as_deref().unwrap_or("-").to_owned(),
187                sprint.status.as_deref().unwrap_or("-").to_owned(),
188                sprint.start.as_deref().unwrap_or("-").to_owned(),
189                sprint.end.as_deref().unwrap_or("-").to_owned(),
190            ]
191        })
192        .collect();
193
194    let mut out = render(&columns, &rows, ctx);
195    out.push_str(&tally(sprints.len(), Some(sprints.len() as u64), None, ctx));
196    out
197}
198
199/// The sprints of a board.
200#[must_use]
201pub fn sprints(board: &str, sprints: &[Sprint], ctx: &Context) -> String {
202    let columns = [
203        Column::whole("ID", 8, Palette::key()),
204        Column::new("NAME", 30, anstyle::Style::new()),
205        Column::new("STATUS", 14, anstyle::Style::new()),
206        Column::whole("START", 12, anstyle::Style::new()),
207        Column::whole("END", 12, anstyle::Style::new()),
208    ];
209    let rows: Vec<Vec<String>> = sprints
210        .iter()
211        .map(|sprint| {
212            vec![
213                sprint.id.clone(),
214                sprint.name.clone(),
215                sprint.status.as_deref().unwrap_or("-").to_owned(),
216                sprint.start.as_deref().unwrap_or("-").to_owned(),
217                sprint.end.as_deref().unwrap_or("-").to_owned(),
218            ]
219        })
220        .collect();
221
222    let mut out = render(&columns, &rows, ctx);
223    let paint = ctx.painter();
224    let _ = writeln!(
225        out,
226        "{}",
227        paint.paint(
228            &format!(
229                "shown {} of {} for board {board}",
230                sprints.len(),
231                sprints.len()
232            ),
233            Palette::label()
234        )
235    );
236    out
237}