Skip to main content

runlight/
reports.rs

1//! Email reports: the period one covers, and the subject, HTML, and text of
2//! one site's report in a language.
3
4use crate::http::Url;
5use crate::intl;
6use crate::js::{self, Value};
7use crate::messages::translator;
8use crate::obj;
9use crate::query::Query;
10use crate::store::{DbError, GoalRow, SiteRow, SqlStore, Stats};
11use crate::time::{add_days, add_months, local_date, start_of};
12
13/// The span of days a report covers, and the one before it.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct ReportPeriod {
16    /// `w:<monday>` or `m:<yyyy-mm>`, so each period is sent once.
17    pub key: String,
18    /// The first day, YYYY-MM-DD.
19    pub from_date: String,
20    /// The last day.
21    pub to_date: String,
22    /// The first day of the period before.
23    pub previous_from: String,
24    /// The last day of the period before.
25    pub previous_to: String,
26    /// Reports go out from 8am the day after the period ends, in the site's timezone.
27    pub due_at: i64,
28}
29
30impl ReportPeriod {
31    /// The SDK's object.
32    pub fn to_value(&self) -> Value {
33        obj! {
34            "key" => self.key.clone(),
35            "fromDate" => self.from_date.clone(),
36            "toDate" => self.to_date.clone(),
37            "previousFrom" => self.previous_from.clone(),
38            "previousTo" => self.previous_to.clone(),
39            "dueAt" => self.due_at,
40        }
41    }
42}
43
44/// The last complete week (Monday to Sunday) or month before `now`, in a
45/// timezone. `frequency` is `weekly` or `monthly`.
46pub fn last_period(frequency: &str, now: i64, timezone: &str) -> ReportPeriod {
47    let today = local_date(now, timezone);
48    if frequency == "monthly" {
49        let first = format!("{}01", js::head16(&today, 8));
50        let from_date = add_months(&first, -1);
51        return ReportPeriod {
52            key: format!("m:{}", js::head16(&from_date, 7)),
53            to_date: add_days(&first, -1),
54            previous_from: add_months(&from_date, -1),
55            previous_to: add_days(&from_date, -1),
56            due_at: start_of(&first, timezone, 8),
57            from_date,
58        };
59    }
60    let (y, m, d) = ymd(&today);
61    // getUTCDay counts from Sunday; this from Monday.
62    let weekday = (js::days_from_civil(y, m, d) + 3).rem_euclid(7);
63    let monday = add_days(&today, -weekday);
64    let from_date = add_days(&monday, -7);
65    ReportPeriod {
66        key: format!("w:{from_date}"),
67        to_date: add_days(&monday, -1),
68        previous_from: add_days(&from_date, -7),
69        previous_to: add_days(&from_date, -1),
70        due_at: start_of(&monday, timezone, 8),
71        from_date,
72    }
73}
74
75fn ymd(date: &str) -> (i64, i64, i64) {
76    let mut it = date.split('-').map(|p| p.parse::<i64>().unwrap_or(0));
77    (it.next().unwrap_or(0), it.next().unwrap_or(1), it.next().unwrap_or(1))
78}
79
80/// Text made safe for HTML.
81fn esc(value: &str) -> String {
82    let mut out = String::with_capacity(value.len());
83    for c in value.chars() {
84        match c {
85            '&' => out.push_str("&amp;"),
86            '<' => out.push_str("&lt;"),
87            '>' => out.push_str("&gt;"),
88            '"' => out.push_str("&quot;"),
89            '\'' => out.push_str("&#39;"),
90            c => out.push(c),
91        }
92    }
93    out
94}
95
96fn duration(ms: f64) -> String {
97    let seconds = js::round(ms / 1000.0);
98    if seconds < 60.0 {
99        return format!("{}s", js::format_number(seconds));
100    }
101    let minutes = (seconds / 60.0).floor();
102    if minutes < 60.0 {
103        return format!("{}m {:0>2}s", js::format_number(minutes), js::format_number(seconds % 60.0));
104    }
105    format!("{}h {:0>2}m", js::format_number((minutes / 60.0).floor()), js::format_number(minutes % 60.0))
106}
107
108/// A report, ready to send.
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct BuiltReport {
111    /// The subject line.
112    pub subject: String,
113    /// The HTML body.
114    pub html: String,
115    /// The plain text body.
116    pub text: String,
117}
118
119struct Delta {
120    text: String,
121    color: &'static str,
122    tone: &'static str,
123}
124
125/// One site's report for a period, in a language. The links are absolute:
126/// the dashboard and the recipient's unsubscribe page.
127pub async fn build_report(
128    store: &SqlStore,
129    site: &SiteRow,
130    frequency: &str,
131    period: &ReportPeriod,
132    lang: &str,
133    dashboard_link: &str,
134    unsubscribe_link: &str,
135) -> Result<BuiltReport, DbError> {
136    let words = translator(lang);
137    let code = words.lang.as_str();
138    let t = |key: &str, vars: &[(&str, Value)]| words.t(key, vars);
139    let tz = site.timezone.as_str();
140    let range = |from: &str, to: &str| Query {
141        site: site.id.clone(),
142        from: start_of(from, tz, 0),
143        to: start_of(&add_days(to, 1), tz, 0),
144        filters: vec![],
145    };
146    let query = range(&period.from_date, &period.to_date);
147    let before = range(&period.previous_from, &period.previous_to);
148    let now = store.stats(&query).await?;
149    let prev = store.stats(&before).await?;
150    let pages = store.breakdown(&query, "page", 5, 0).await?;
151    let sources = store.breakdown(&query, "source", 5, 0).await?;
152    let countries = store.breakdown(&query, "country", 5, 0).await?;
153    let goals = store.goals(Some(&site.id)).await?;
154    let totals = store.goal_totals_all(&query, &goals).await?;
155    let mut goal_rows: Vec<(&GoalRow, crate::store::GoalTotals)> =
156        goals.iter().map(|g| (g, totals.get(&g.id).cloned().unwrap_or_default())).collect();
157
158    let number = |n: f64| intl::number(code, n, 0, 3);
159    let percent = |n: f64| intl::percent(code, n);
160    let money = |n: f64, currency: &str| intl::currency(code, n, currency, if js::is_integer(n) { 0 } else { 2 });
161    let month_name = |d: &str| intl::month_year(code, d);
162    // Each end formatted on its own, joined in the reader's language (never with a dash).
163    let span = |from: &str, to: &str| {
164        let same_year = js::head16(from, 4) == js::head16(to, 4);
165        t(
166            "email.range",
167            &[("from", intl::short_day(code, from, !same_year).into()), ("to", intl::short_day(code, to, true).into())],
168        )
169    };
170
171    let monthly = frequency == "monthly";
172    let when = if monthly {
173        t("email.when.month", &[("month", month_name(&period.from_date).into())])
174    } else {
175        t("email.when.week", &[])
176    };
177    let against = if monthly { month_name(&period.previous_from) } else { t("email.before.week", &[]) };
178    let who = words.tn("headline.who", now.visitors, &[("n", number(now.visitors).into())]);
179    let verb = words.tn("headline.visited", now.visitors, &[]);
180    let change = if prev.visitors != 0.0 && !prev.visitors.is_nan() {
181        Some((now.visitors - prev.visitors) / prev.visitors)
182    } else {
183        None
184    };
185    let headline = if prev.visitors == 0.0 && now.visitors > 0.0 {
186        t(
187            "headline.fromNone",
188            &[
189                ("who", who.clone().into()),
190                ("verb", verb.clone().into()),
191                ("when", when.clone().into()),
192                ("against", against.clone().into()),
193            ],
194        )
195    } else {
196        match change {
197            None => t(
198                "headline.plain",
199                &[("who", who.clone().into()), ("verb", verb.clone().into()), ("when", when.clone().into())],
200            ),
201            Some(change) => {
202                let key = if change.abs() < 0.005 {
203                    "headline.same"
204                } else if change > 0.0 {
205                    "headline.up"
206                } else {
207                    "headline.down"
208                };
209                let pct = js::round(change * 100.0).abs();
210                let change_text =
211                    t(if change > 0.0 { "headline.more" } else { "headline.fewer" }, &[("pct", pct.into())]);
212                t(
213                    key,
214                    &[
215                        ("who", who.clone().into()),
216                        ("verb", verb.clone().into()),
217                        ("when", when.clone().into()),
218                        ("against", against.clone().into()),
219                        ("change", change_text.into()),
220                    ],
221                )
222            }
223        }
224    };
225    let subject = t(
226        if monthly { "email.subject.month" } else { "email.subject.week" },
227        &[
228            ("site", site.name.clone().into()),
229            ("who", who.clone().into()),
230            ("month", month_name(&period.from_date).into()),
231        ],
232    );
233    let dates = span(&period.from_date, &period.to_date);
234
235    // Each metric: its key, how it is written, and whether lower is better.
236    let metrics: [(&str, bool); 6] = [
237        ("visitors", false),
238        ("visits", false),
239        ("pageviews", false),
240        ("viewsPerVisit", false),
241        ("bounceRate", true),
242        ("visitDuration", false),
243    ];
244    let format = |key: &str, n: f64| match key {
245        "viewsPerVisit" => intl::number(code, n, 1, 1),
246        "bounceRate" => percent(n),
247        "visitDuration" => duration(n),
248        _ => number(n),
249    };
250    let delta = |key: &str, lower_is_better: bool, now: &Stats, prev: &Stats| -> Delta {
251        let b = prev.get(key);
252        if b == 0.0 || b.is_nan() {
253            return Delta { text: String::new(), color: "#6b7280", tone: "flat" };
254        }
255        let c = (now.get(key) - b) / b;
256        if c.abs() < 0.005 {
257            return Delta { text: "0%".into(), color: "#6b7280", tone: "flat" };
258        }
259        let good = if lower_is_better { c < 0.0 } else { c > 0.0 };
260        Delta {
261            text: format!("{} {}", if c > 0.0 { "↑" } else { "↓" }, percent(c.abs())),
262            color: if good { "#15803d" } else { "#b91c1c" },
263            tone: if good { "up" } else { "down" },
264        }
265    };
266
267    let value_of = |r: &Value| js::str_or_empty(r.get("value"));
268    let visitors_of = |r: &Value| number(js::opt_number(r.get("visitors")));
269    let mut lists: Vec<(String, Vec<(String, String)>)> = vec![
270        (
271            t("email.pages", &[]),
272            pages
273                .iter()
274                .map(|r| {
275                    let v = value_of(r);
276                    (if v.is_empty() { "/".to_string() } else { v }, visitors_of(r))
277                })
278                .collect(),
279        ),
280        (
281            t("email.sources", &[]),
282            sources
283                .iter()
284                .map(|r| {
285                    let v = value_of(r);
286                    (if v.is_empty() { t("goals.unknown", &[]) } else { v }, visitors_of(r))
287                })
288                .collect(),
289        ),
290        (
291            t("email.countries", &[]),
292            countries.iter().map(|r| (intl::region(code, &value_of(r)), visitors_of(r))).collect(),
293        ),
294    ];
295    if !goal_rows.is_empty() {
296        goal_rows.sort_by(|a, b| b.1.conversions.partial_cmp(&a.1.conversions).unwrap_or(std::cmp::Ordering::Equal));
297        lists.push((
298            t("email.conversions", &[]),
299            goal_rows
300                .iter()
301                .map(|(goal, totals)| {
302                    let name = if goal.value_mode != "none" && totals.revenue != 0.0 && !totals.revenue.is_nan() {
303                        format!("{} ({})", goal.name, money(totals.revenue, &goal.currency))
304                    } else {
305                        goal.name.clone()
306                    };
307                    (name, number(totals.conversions))
308                })
309                .collect(),
310        ));
311    }
312
313    let font = "-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif";
314    let cell = |(key, lower): &(&str, bool)| {
315        let d = delta(key, *lower, &now, &prev);
316        format!(
317            "<td width=\"33%\" class=\"rl-line\" style=\"padding:12px 14px;border:1px solid #e5e7eb;border-radius:10px;vertical-align:top\">
318<div class=\"rl-muted\" style=\"font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:#6b7280\">{}</div>
319<div class=\"rl-ink\" style=\"font-size:24px;font-weight:600;color:#111827;margin-top:4px\">{}</div>
320<div class=\"rl-{}\" style=\"font-size:12px;color:{};margin-top:2px;min-height:16px\">{}</div></td>",
321            esc(&t(&format!("metric.{key}"), &[])),
322            esc(&format(key, now.get(key))),
323            d.tone,
324            d.color,
325            esc(&d.text)
326        )
327    };
328    let table = |(title, rows): &(String, Vec<(String, String)>)| {
329        let body = if rows.is_empty() {
330            format!(
331                "<tr><td class=\"rl-muted\" style=\"padding:7px 0;color:#6b7280\">{}</td></tr>",
332                esc(&t("panel.empty", &[]))
333            )
334        } else {
335            rows.iter()
336                .map(|(a, b)| {
337                    format!(
338                        "<tr><td class=\"rl-row rl-body-text\" style=\"padding:7px 0;border-top:1px solid #f0f0f0;color:#374151;word-break:break-all\">{}</td><td align=\"right\" class=\"rl-row rl-ink\" style=\"padding:7px 0 7px 12px;border-top:1px solid #f0f0f0;color:#111827;font-weight:600;white-space:nowrap\">{}</td></tr>",
339                        esc(a),
340                        esc(b)
341                    )
342                })
343                .collect::<String>()
344        };
345        format!(
346            "<h3 class=\"rl-ink\" style=\"font-size:14px;color:#111827;margin:28px 0 8px\">{}</h3>
347<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"border-collapse:collapse;font-size:14px\">{body}</table>",
348            esc(title)
349        )
350    };
351
352    // Where the dashboard lives, without the scheme or the site query, so a reader
353    // with several installs can tell which one sent this.
354    let place = match Url::parse(dashboard_link) {
355        Some(u) => {
356            let path = u.pathname();
357            format!("{}{}", u.host(), path.strip_suffix('/').unwrap_or(&path))
358        }
359        None => dashboard_link.to_string(),
360    };
361    let at = t("email.at", &[("where", place.clone().into())]);
362    // The Runlight mark in table cells: mail apps block SVG and most inline images.
363    let mark = format!(
364        "<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" style=\"border-collapse:collapse\"><tr>
365<td class=\"rl-mark\" width=\"24\" height=\"24\" align=\"center\" style=\"width:24px;height:24px;background:#111827;border-radius:7px;color:#ffffff;font-size:15px;font-weight:700;line-height:24px;text-align:center;font-family:{font}\">R</td>
366<td class=\"rl-ink\" style=\"padding-left:8px;font-size:15px;font-weight:700;color:#111827;font-family:{font}\">Runlight</td></tr></table>"
367    );
368
369    let footer = t(
370        "email.footer",
371        &[
372            ("frequency", t(if monthly { "email.monthly" } else { "email.weekly" }, &[]).into()),
373            ("site", site.name.clone().into()),
374        ],
375    );
376    let at_link = esc(&t("email.at", &[("where", "\u{0}".into())])).replacen(
377        '\u{0}',
378        &format!("<a href=\"{}\" class=\"rl-muted\" style=\"color:#6b7280\">{}</a>", esc(dashboard_link), esc(&place)),
379        1,
380    );
381    let first_row: String = metrics[..3].iter().map(cell).collect();
382    let second_row: String = metrics[3..].iter().map(cell).collect();
383    let tables: String = lists.iter().map(table).collect();
384    let html = format!(
385        "<!doctype html><html lang=\"{code}\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><meta name=\"color-scheme\" content=\"light dark\"><meta name=\"supported-color-schemes\" content=\"light dark\"><title>{subject_html}</title>
386<style>
387@media (prefers-color-scheme: dark) {{
388  .rl-page {{ background: #09090b !important; }}
389  .rl-card {{ background: #141417 !important; border-color: #27272a !important; }}
390  .rl-line {{ border-color: #27272a !important; }}
391  .rl-row {{ border-top-color: #1f1f23 !important; }}
392  .rl-ink {{ color: #ffffff !important; }}
393  .rl-body-text {{ color: #d4d4d8 !important; }}
394  .rl-muted, .rl-flat {{ color: #a1a1aa !important; }}
395  .rl-up {{ color: #4ade80 !important; }}
396  .rl-down {{ color: #f87171 !important; }}
397  .rl-button {{ background: #ffffff !important; color: #000000 !important; }}
398  .rl-mark {{ background: #ffffff !important; color: #000000 !important; }}
399  .rl-foot, .rl-foot a {{ color: #a1a1aa !important; }}
400}}
401</style></head>
402<body class=\"rl-page\" style=\"margin:0;padding:0;background:#f4f4f5;font-family:{font}\">
403<div style=\"display:none;max-height:0;overflow:hidden\">{headline_html}</div>
404<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" class=\"rl-page\" style=\"background:#f4f4f5\"><tr><td align=\"center\" style=\"padding:32px 16px\">
405<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" class=\"rl-card\" style=\"max-width:600px;background:#ffffff;border-radius:14px;border:1px solid #e5e7eb\"><tr><td style=\"padding:32px\">
406<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"border-collapse:collapse;margin:0 0 24px\"><tr>
407<td style=\"vertical-align:middle\">{mark}</td>
408<td align=\"right\" class=\"rl-muted\" style=\"vertical-align:middle;font-size:12px;color:#6b7280\">{at_link}</td>
409</tr></table>
410<div class=\"rl-muted\" style=\"font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:#6b7280\">{site_html} · {dates_html}</div>
411<h1 class=\"rl-ink\" style=\"font-size:24px;line-height:1.3;color:#111827;margin:10px 0 24px;font-weight:600\">{headline_html}</h1>
412<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"6\" style=\"border-collapse:separate;margin:0 -6px\">
413<tr>{first_row}</tr><tr>{second_row}</tr></table>
414{tables}
415<p style=\"margin:32px 0 0\"><a href=\"{dashboard_html}\" class=\"rl-button\" style=\"display:inline-block;background:#111827;color:#ffffff;text-decoration:none;padding:11px 18px;border-radius:8px;font-size:14px;font-weight:600\">{open_html}</a></p>
416</td></tr></table>
417<p class=\"rl-foot\" style=\"max-width:600px;font-size:12px;line-height:1.5;color:#6b7280;margin:16px auto 0\">{footer_html} <a href=\"{unsubscribe_html}\" style=\"color:#6b7280\">{unsubscribe_text}</a></p>
418</td></tr></table></body></html>",
419        subject_html = esc(&subject),
420        headline_html = esc(&headline),
421        site_html = esc(&site.name),
422        dates_html = esc(&dates),
423        dashboard_html = esc(dashboard_link),
424        open_html = esc(&t("email.open", &[])),
425        footer_html = esc(&footer),
426        unsubscribe_html = esc(unsubscribe_link),
427        unsubscribe_text = esc(&t("email.unsubscribe", &[])),
428    );
429
430    // French sets a space before a colon, as its subject line does.
431    let colon = if code == "fr" { "\u{a0}:" } else { ":" };
432    let mut lines: Vec<String> = vec![
433        format!("Runlight · {at}"),
434        String::new(),
435        format!("{} · {dates}", site.name),
436        String::new(),
437        headline.clone(),
438        String::new(),
439    ];
440    for (key, lower) in &metrics {
441        let d = delta(key, *lower, &now, &prev).text;
442        let tail = if d.is_empty() { String::new() } else { format!(" ({d})") };
443        lines.push(format!("{}{colon} {}{tail}", t(&format!("metric.{key}"), &[]), format(key, now.get(key))));
444    }
445    for (title, rows) in &lists {
446        lines.push(String::new());
447        lines.push(title.clone());
448        if rows.is_empty() {
449            lines.push(format!("  {}", t("panel.empty", &[])));
450        } else {
451            for (a, b) in rows {
452                lines.push(format!("  {a}{colon} {b}"));
453            }
454        }
455    }
456    lines.push(String::new());
457    lines.push(format!("{}{colon} {dashboard_link}", t("email.open", &[])));
458    lines.push(String::new());
459    lines.push(format!("{footer} {}{colon} {unsubscribe_link}", t("email.unsubscribe", &[])));
460
461    Ok(BuiltReport { subject, html, text: lines.join("\n") })
462}