Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
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
6pub mod change;
7pub mod design;
8pub mod diff;
9pub mod edit;
10pub mod issue;
11pub mod layout;
12pub mod pages;
13pub mod repo;
14pub mod review;
15pub mod settings;
16
17pub use layout::{error_page, page, page_full, page_with_bar, Chrome};
18
19use chrono::{DateTime, Utc};
20use 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.
28pub 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.
39pub 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.
59pub 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)]
85mod 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