Files
codecraft/server/src/digest.rs
T

129 lines
6.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Daily cost summary as a push notification (20:00 local time, opt-in per
//! user via the user setting "cost_push").
use crate::app::App;
use chrono::{Datelike, Local, Timelike};
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
const HOUR: u32 = 20;
fn money(v: f64, currency: &str, de: bool) -> String {
let s = format!("{v:.2}");
let s = if de { s.replace('.', ",") } else { s };
match currency {
"" | "USD" => if de { format!("{s} $") } else { format!("${s}") },
"EUR" => format!("{s} €"),
c => format!("{s} {c}"),
}
}
/// Today's summary for one user (None when nothing was used).
pub fn summary(app: &App, user_id: &str) -> Option<Value> {
let now = Local::now();
let start = now.with_hour(0)?.with_minute(0)?.with_second(0)?.timestamp_millis();
let offset = now.offset().local_minus_utc() as i64 / 60;
let stats = app.store.global_stats(start, now.timestamp_millis() + 1, offset, Some(user_id)).ok()?;
let requests = stats["total"]["requests"].as_i64().unwrap_or(0);
if requests == 0 {
return None;
}
let prefs: Value = app.store.user_setting(user_id, "prefs").unwrap_or_default();
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
let currency = app.config().server.currency.clone();
let cost = stats["total"]["cost"].as_f64().unwrap_or(0.0);
let top = stats["by_model"]
.as_array()
.and_then(|m| m.iter().max_by(|a, b| a["cost"].as_f64().unwrap_or(0.0).total_cmp(&b["cost"].as_f64().unwrap_or(0.0))))
.and_then(|m| m["model"].as_str())
.unwrap_or("")
.to_string();
let sessions = stats["top_sessions"].as_array().map(|a| a.len()).unwrap_or(0);
let body = if de {
format!("{} · {requests} Anfragen in {sessions} Chats{}", money(cost, &currency, true), if top.is_empty() { String::new() } else { format!(" · meist {top}") })
} else {
format!("{} · {requests} requests in {sessions} chats{}", money(cost, &currency, false), if top.is_empty() { String::new() } else { format!(" · mostly {top}") })
};
Some(json!({
"kind": "digest",
"title": if de { "CodeCraft – Kosten heute" } else { "CodeCraft – today's costs" },
"body": body,
"tag": "cost-digest",
"url": "/#/stats",
}))
}
/// The last seven days for one user (None when nothing happened).
pub fn weekly(app: &App, user_id: &str) -> Option<Value> {
let now = Local::now();
let since = now.timestamp_millis() - 7 * 86_400_000;
let offset = now.offset().local_minus_utc() as i64 / 60;
let stats = app.store.global_stats(since, now.timestamp_millis() + 1, offset, Some(user_id)).ok()?;
let requests = stats["total"]["requests"].as_i64().unwrap_or(0);
let (chats, failed, checks) = app.store.weekly_counts(user_id, since).ok()?;
if requests == 0 && chats == 0 {
return None;
}
let prefs: Value = app.store.user_setting(user_id, "prefs").unwrap_or_default();
let de = prefs["lang"].as_str().unwrap_or("").starts_with("de");
let currency = app.config().server.currency.clone();
let cost = money(stats["total"]["cost"].as_f64().unwrap_or(0.0), &currency, de);
let top = stats["top_sessions"].as_array().and_then(|a| a.first()).and_then(|s| s["title"].as_str()).filter(|t| !t.is_empty()).map(str::to_string);
let mut parts = if de { vec![format!("{chats} Chats aktiv"), format!("{requests} Anfragen · {cost}")] } else { vec![format!("{chats} chats active"), format!("{requests} requests · {cost}")] };
if checks > 0 {
parts.push(if de { format!("Checks: {failed} von {checks} fehlgeschlagen") } else { format!("checks: {failed} of {checks} failed") });
}
if let Some(t) = top {
parts.push(if de { format!("teuerster Chat: {t}") } else { format!("most expensive chat: {t}") });
}
Some(json!({
"kind": "digest",
"title": if de { "CodeCraft – Wochenrückblick" } else { "CodeCraft – weekly review" },
"body": parts.join("\n"),
"tag": "weekly-digest",
"url": "/#/stats",
}))
}
pub fn spawn(app: Arc<App>) {
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
let now = Local::now();
// Weekly review: Monday morning, opt-in (user setting "weekly_push").
if now.hour() == 8 && now.weekday() == chrono::Weekday::Mon {
let week = now.format("%G-W%V").to_string();
for user in app.store.users().unwrap_or_default() {
let on: bool = app.store.user_setting(&user.id, "weekly_push").unwrap_or(false);
let last: String = app.store.user_setting(&user.id, "weekly_push_last").unwrap_or_default();
if !on || user.disabled || last == week {
continue;
}
let _ = app.store.set_user_setting(&user.id, "weekly_push_last", &week);
if let Some(payload) = weekly(&app, &user.id) {
let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await;
tracing::info!(user = %user.username, sent, errors = errors.len(), "weekly review");
}
}
}
if now.hour() != HOUR {
continue;
}
let today = now.format("%Y-%m-%d").to_string();
for user in app.store.users().unwrap_or_default() {
let on: bool = app.store.user_setting(&user.id, "cost_push").unwrap_or(false);
let last: String = app.store.user_setting(&user.id, "cost_push_last").unwrap_or_default();
if !on || user.disabled || last == today {
continue;
}
let _ = app.store.set_user_setting(&user.id, "cost_push_last", &today);
if let Some(payload) = summary(&app, &user.id) {
let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await;
tracing::info!(user = %user.username, sent, errors = errors.len(), "daily cost summary");
}
}
}
});
}