//! 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::{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 { 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, ¤cy, true), if top.is_empty() { String::new() } else { format!(" · meist {top}") }) } else { format!("{} · {requests} requests in {sessions} chats{}", money(cost, ¤cy, 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", })) } pub fn spawn(app: Arc) { tokio::spawn(async move { loop { tokio::time::sleep(Duration::from_secs(60)).await; let now = Local::now(); 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"); } } } }); }