1use 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#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct ReportPeriod {
16 pub key: String,
18 pub from_date: String,
20 pub to_date: String,
22 pub previous_from: String,
24 pub previous_to: String,
26 pub due_at: i64,
28}
29
30impl ReportPeriod {
31 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
44pub 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 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
80fn 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("&"),
86 '<' => out.push_str("<"),
87 '>' => out.push_str(">"),
88 '"' => out.push_str("""),
89 '\'' => out.push_str("'"),
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#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct BuiltReport {
111 pub subject: String,
113 pub html: String,
115 pub text: String,
117}
118
119struct Delta {
120 text: String,
121 color: &'static str,
122 tone: &'static str,
123}
124
125pub 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 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 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 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 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 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}