1use crate::day::{civil_day, day_start};
2use crate::money::none;
3use crate::render::{digits, fixed, grouped};
4use patterns_macros::{because, source};
5
6pub struct ReaderConventions;
7source!(ReaderConventions, "the ways the dashboard's readers are used to seeing a figure, a date and an age written, carried over from the retired client so a number reads the same on both");
8
9const POINT: u8 = 46;
10because!(POINT, ReaderConventions, "the decimal point, written as its code point because a character literal is a value from nowhere in this zone");
11
12const MINUS: u8 = 45;
13because!(MINUS, ReaderConventions, "the sign a negative figure carries, written as its code point since a character literal is a value from nowhere in this zone");
14
15const DASH: u8 = 45;
16because!(DASH, ReaderConventions, "the separator between the parts of a machine-readable date, the same code point as the sign, which it is");
17
18const SLASH: u8 = 47;
19because!(SLASH, ReaderConventions, "the separator between the parts of a date as the tables show it");
20
21const ZERO: u8 = 48;
22because!(ZERO, ReaderConventions, "the digit that pads a month or a day to two places");
23
24const DOLLAR_MARK: &str = "$ ";
25because!(DOLLAR_MARK, ReaderConventions, "the sign and the space that lead every dollar figure");
26
27const PERCENT_MARK: &str = "%";
28because!(PERCENT_MARK, ReaderConventions, "the sign that follows every percentage");
29
30const USD_MIN_PLACES: usize = 2;
31because!(USD_MIN_PLACES, ReaderConventions, "the places a dollar figure always shows, cents, unless the caller asks for fewer");
32
33const DATE_PLACES: usize = 2;
34because!(DATE_PLACES, ReaderConventions, "the width a month or day number is padded to in a date");
35
36const DAY_SECONDS: u64 = 86_400;
37because!(DAY_SECONDS, ReaderConventions, "the seconds in a day, which an age counts days by and a date is floored to");
38
39const HOUR_SECONDS: u64 = 3_600;
40because!(HOUR_SECONDS, ReaderConventions, "the seconds in an hour, which an age counts hours by");
41
42const MINUTE_SECONDS: u64 = 60;
43because!(MINUTE_SECONDS, ReaderConventions, "the seconds in the smallest unit an age is shown in");
44
45const YEAR_DAYS: u64 = 365;
46because!(YEAR_DAYS, ReaderConventions, "the days in a year as an age counts them, without leap days, the way the retired client did");
47
48const BYTE_BITS: u32 = 8;
49because!(BYTE_BITS, ReaderConventions, "the bits one colour channel takes in a packed colour");
50
51const BYTE_MASK: u32 = 0xff;
52because!(BYTE_MASK, ReaderConventions, "the mask that keeps one colour channel of a packed colour");
53
54const CHANNELS_BELOW_RED: u32 = 2;
55because!(CHANNELS_BELOW_RED, ReaderConventions, "the channels packed below the red one, green and blue, which red is shifted past");
56
57const RED_SHIFT: u32 = BYTE_BITS * CHANNELS_BELOW_RED;
58
59fn padded(value: u32) -> String {
60 let plain = digits(value);
61 let mut out = String::new();
62 for _ in plain.len()..DATE_PLACES {
63 out.push(ZERO as char);
64 }
65 out.push_str(&plain);
66 out
67}
68
69pub fn day_text(day: u64) -> String {
70 let (year, month, d) = civil_day(day, DAY_SECONDS);
71 let mut out = digits(year);
72 out.push(SLASH as char);
73 out.push_str(&padded(month));
74 out.push(SLASH as char);
75 out.push_str(&padded(d));
76 out
77}
78because!(day_text, "a day as the tables show it, year then month then day with slashes between");
79
80pub fn iso_day_text(day: u64) -> String {
81 let (year, month, d) = civil_day(day, DAY_SECONDS);
82 let parts = [digits(year), padded(month), padded(d)];
83 parts.join(&(DASH as char).to_string())
84}
85because!(iso_day_text, "a day in the machine-readable form a date input and a spreadsheet read, year then month then day with dashes between");
86
87pub fn day_from_iso(text: &str) -> Option<u64> {
88 let mut parts = text.split(DASH as char);
89 let year: i64 = parts.next()?.parse().ok()?;
90 let month: u32 = parts.next()?.parse().ok()?;
91 let day: u32 = parts.next()?.parse().ok()?;
92 Some(day_start(year, month, day, DAY_SECONDS))
93}
94because!(day_from_iso, "the moment a date input names, the start of that day, read back through the same civil calendar the day was written with");
95
96pub fn grouped_places(value: f64, min_places: usize, max_places: usize) -> String {
97 let text = fixed(value.abs(), max_places);
98 let (whole, fraction) = match text.split_once(POINT as char) {
99 Some((w, f)) => (String::from(w), String::from(f)),
100 None => (text.clone(), String::new()),
101 };
102 let mut kept = fraction.trim_end_matches(ZERO as char).to_string();
103 while kept.len() < min_places {
104 kept.push(ZERO as char);
105 }
106 let whole_value = whole.parse::<u64>().unwrap_or(0);
107 let mut out = String::new();
108 if value < none() && (whole_value > 0 || !kept.chars().all(|c| c == ZERO as char)) {
109 out.push(MINUS as char);
110 }
111 out.push_str(&grouped(whole_value));
112 if !kept.is_empty() {
113 out.push(POINT as char);
114 out.push_str(&kept);
115 }
116 out
117}
118because!(grouped_places, "a figure with separators in its whole part and between a floor and a ceiling of places after the point, trailing zeros dropped down to the floor, the way the retired client's locale formatting wrote every number");
119
120pub fn usd_text(value: f64, max_places: usize) -> String {
121 let mut out = String::from(DOLLAR_MARK);
122 out.push_str(&grouped_places(value, USD_MIN_PLACES.min(max_places), max_places));
123 out
124}
125because!(usd_text, "a dollar figure with its sign, always showing cents unless fewer places were asked for");
126
127pub fn percent_text(value: f64, places: usize) -> String {
128 let mut out = fixed(value, places);
129 out.push_str(PERCENT_MARK);
130 out
131}
132because!(percent_text, "a percentage with a fixed count of places and its sign");
133
134pub fn grouped_percent_text(value: f64, places: usize) -> String {
135 let mut out = grouped_places(value, 0, places);
136 out.push_str(PERCENT_MARK);
137 out
138}
139because!(grouped_percent_text, "a percentage with separators and up to a count of places, the form a history column of kind percent takes");
140
141pub fn count_text(value: u64) -> String {
142 grouped(value)
143}
144because!(count_text, "a count with separators, the form every row count and rank is shown in");
145
146pub fn duration_parts(now: u64, then: u64) -> (u64, u64, u64, u64) {
147 let total = now.saturating_sub(then);
148 let days = total / DAY_SECONDS;
149 let hours = (total - days * DAY_SECONDS) / HOUR_SECONDS;
150 let smallest = (total - days * DAY_SECONDS - hours * HOUR_SECONDS) / MINUTE_SECONDS;
151 let years = days / YEAR_DAYS;
152 (years, days, hours, smallest)
153}
154because!(duration_parts, "an age split into whole years, days, hours and the smallest unit shown, the parts an age text is chosen from");
155
156pub fn duration_text(now: u64, then: u64, year_word: &str, years_word: &str, days_word: &str, hours_word: &str, smallest_word: &str) -> String {
157 if then == 0 {
158 return String::new();
159 }
160 let (years, days, hours, smallest) = duration_parts(now, then);
161 let mut out = String::new();
162 match (years > 0, days > 0) {
163 (true, _) => {
164 out.push_str(&digits(years));
165 out.push_str(match years > 1 {
166 true => years_word,
167 false => year_word,
168 });
169 out.push_str(&digits(days - years * YEAR_DAYS));
170 out.push_str(days_word);
171 }
172 (false, true) => {
173 out.push_str(&digits(days));
174 out.push_str(days_word);
175 out.push_str(&digits(hours));
176 out.push_str(hours_word);
177 }
178 (false, false) => {
179 out.push_str(&digits(hours));
180 out.push_str(hours_word);
181 out.push_str(&digits(smallest));
182 out.push_str(smallest_word);
183 }
184 }
185 out.trim_end().to_string()
186}
187because!(duration_text, "an age as the retired client wrote it: years and days past a year, days and hours past a day, hours and the smallest unit under a day, with the unit words handed in by the spec");
188
189const QUOTE: u8 = 34;
190because!(QUOTE, ReaderConventions, "the mark that wraps a spreadsheet cell holding a separator, a quote or a line break");
191
192const COMMA: u8 = 44;
193because!(COMMA, ReaderConventions, "the separator between spreadsheet cells");
194
195const LINE_BREAK: u8 = 10;
196because!(LINE_BREAK, ReaderConventions, "the separator between spreadsheet rows");
197
198pub fn csv_cell(text: &str) -> String {
199 let needs_quotes = text.chars().any(|c| c == QUOTE as char || c == COMMA as char || c == LINE_BREAK as char);
200 match needs_quotes {
201 true => {
202 let mut out = String::new();
203 out.push(QUOTE as char);
204 for c in text.chars() {
205 if c == QUOTE as char {
206 out.push(QUOTE as char);
207 }
208 out.push(c);
209 }
210 out.push(QUOTE as char);
211 out
212 }
213 false => String::from(text),
214 }
215}
216because!(csv_cell, "one spreadsheet cell, quoted and with its quotes doubled only when it holds a character the format reserves");
217
218pub fn csv_text(header: &[String], rows: &[Vec<String>]) -> String {
219 let mut lines: Vec<String> = Vec::with_capacity(rows.len() + 1);
220 let line = |cells: &[String]| cells.iter().map(|c| csv_cell(c)).collect::<Vec<String>>().join(&(COMMA as char).to_string());
221 lines.push(line(header));
222 for row in rows {
223 lines.push(line(row));
224 }
225 lines.join(&(LINE_BREAK as char).to_string())
226}
227because!(csv_text, "a header and rows as one spreadsheet text, the download a report offers");
228
229pub fn hex_color(ink: u32) -> String {
230 format!("#{:06x}", ink)
231}
232because!(hex_color, "a colour written the way a style sheet reads it, from the number the spec states");
233
234pub fn rgba_text(ink: u32, alpha: f64) -> String {
235 let red = (ink >> RED_SHIFT) & BYTE_MASK;
236 let green = (ink >> BYTE_BITS) & BYTE_MASK;
237 let blue = ink & BYTE_MASK;
238 format!("rgba({}, {}, {}, {})", red, green, blue, alpha)
239}
240because!(rgba_text, "a colour with its opacity written the way a style sheet reads it, for the fill under a chart line");
241
242pub fn points_table(columns: &[(String, Vec<(u64, f64)>)], day_text: fn(u64) -> String) -> (Vec<String>, Vec<Vec<String>>) {
243 let mut days: Vec<u64> = columns.iter().flat_map(|(_, points)| points.iter().map(|(day, _)| *day)).collect();
244 days.sort_unstable();
245 days.dedup();
246 let header: Vec<String> = columns.iter().map(|(name, _)| name.clone()).collect();
247 let rows: Vec<Vec<String>> = days
248 .iter()
249 .map(|day| {
250 let mut row = vec![day_text(*day)];
251 for (_, points) in columns {
252 row.push(points.iter().find(|(d, _)| d == day).map(|(_, v)| digits(v)).unwrap_or_default());
253 }
254 row
255 })
256 .collect();
257 (header, rows)
258}
259because!(points_table, "several named lines as one table with a row per day and an empty cell where a line has no point, the daily spreadsheet the report offers");