| 1 | //! Maud templates. | |
| 2 | //! | |
| 3 | //! Every fragment that htmx can swap is also a function that renders | |
| 4 | //! standalone, so each URL works without JavaScript (spec §7). | |
| 5 | ||
| 6 | pub mod change; | |
| 7 | pub mod design; | |
| 8 | pub mod diff; | |
| 9 | pub mod edit; | |
| 10 | pub mod issue; | |
| 11 | pub mod layout; | |
| 12 | pub mod pages; | |
| 13 | pub mod repo; | |
| 14 | pub mod review; | |
| 15 | pub mod settings; | |
| 16 | ||
| 17 | pub use layout::{error_page, page, page_full, page_with_bar, Chrome}; | |
| 18 | ||
| 19 | use chrono::{DateTime, Utc}; | |
| 20 | use maud::{html, Markup}; | |
| 21 | ||
| 22 | /// A Dogfood handle, linked to its profile. | |
| 23 | /// | |
| 24 | /// Every handle the UI renders is a person with a page, so linking is the | |
| 25 | /// default rather than something each call site decides. Kept here rather than | |
| 26 | /// in one view module because comments, reviews, timelines, and listings all | |
| 27 | /// need it and none of them owns the concept. | |
| 28 | pub fn user_link(handle: &str) -> Markup { | |
| 29 | html! { | |
| 30 | a .user-link href=(format!("/{handle}")) { (handle) } | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | /// A person who may or may not have an account. | |
| 35 | /// | |
| 36 | /// `handle` links; a bare commit name renders as text with a tooltip saying | |
| 37 | /// why it does not. Only a commit with no name at all falls through to | |
| 38 | /// "someone" — see `pages::actor`, which this generalises. | |
| 39 | pub fn person(handle: Option<&str>, name: Option<&str>) -> Markup { | |
| 40 | html! { | |
| 41 | @match (handle, name.map(str::trim).filter(|n| !n.is_empty())) { | |
| 42 | (Some(h), _) => (user_link(h)), | |
| 43 | (None, Some(n)) => span title="this commit is not linked to a Dogfood account" { (n) }, | |
| 44 | (None, None) => span .faint { "someone" }, | |
| 45 | } | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | /// A short, coarse "how long ago" label. | |
| 50 | /// | |
| 51 | /// Feeds and file listings in the design are scanned, not read — a reader wants | |
| 52 | /// to know whether something is fresh, not the minute it landed. Coarse buckets | |
| 53 | /// say that faster than a timestamp does, so the exact time goes in a `title` | |
| 54 | /// attribute at the call site for anyone who needs it. | |
| 55 | /// | |
| 56 | /// Deliberately has no "just now" case below a minute: a feed row that says | |
| 57 | /// "0m" reads as broken, and one that says "just now" goes stale the moment the | |
| 58 | /// page is cached. | |
| 59 | pub fn relative_time(then: DateTime<Utc>, now: DateTime<Utc>) -> String { | |
| 60 | let secs = (now - then).num_seconds(); | |
| 61 | ||
| 62 | // A clock skew between the app and the database should not render as a | |
| 63 | // date in the future. | |
| 64 | if secs <= 0 { | |
| 65 | return "now".into(); | |
| 66 | } | |
| 67 | ||
| 68 | const MINUTE: i64 = 60; | |
| 69 | const HOUR: i64 = 60 * MINUTE; | |
| 70 | const DAY: i64 = 24 * HOUR; | |
| 71 | const MONTH: i64 = 30 * DAY; | |
| 72 | const YEAR: i64 = 365 * DAY; | |
| 73 | ||
| 74 | match secs { | |
| 75 | s if s < MINUTE => "now".into(), | |
| 76 | s if s < HOUR => format!("{}m", s / MINUTE), | |
| 77 | s if s < DAY => format!("{}h", s / HOUR), | |
| 78 | s if s < MONTH => format!("{}d", s / DAY), | |
| 79 | s if s < YEAR => format!("{}mo", s / MONTH), | |
| 80 | s => format!("{}y", s / YEAR), | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | #[cfg(test)] | |
| 85 | mod relative_time_tests { | |
| 86 | use super::relative_time; | |
| 87 | use chrono::{Duration, Utc}; | |
| 88 | ||
| 89 | #[test] | |
| 90 | fn buckets_read_as_labels() { | |
| 91 | let now = Utc::now(); | |
| 92 | let ago = |d: Duration| relative_time(now - d, now); | |
| 93 | ||
| 94 | assert_eq!(ago(Duration::seconds(5)), "now"); | |
| 95 | assert_eq!(ago(Duration::minutes(3)), "3m"); | |
| 96 | assert_eq!(ago(Duration::hours(5)), "5h"); | |
| 97 | assert_eq!(ago(Duration::days(9)), "9d"); | |
| 98 | assert_eq!(ago(Duration::days(70)), "2mo"); | |
| 99 | assert_eq!(ago(Duration::days(800)), "2y"); | |
| 100 | } | |
| 101 | ||
| 102 | /// A row timestamped slightly in the future is a clock skew, not a | |
| 103 | /// scheduled post — it must not render as one. | |
| 104 | #[test] | |
| 105 | fn a_future_timestamp_does_not_render_as_negative() { | |
| 106 | let now = Utc::now(); | |
| 107 | assert_eq!(relative_time(now + Duration::hours(3), now), "now"); | |
| 108 | } | |
| 109 | } |
109 lines · Rust