karma-web: online status, user counts, join links, rising/chart/most-debated/milestones/reasons, filter+sort, expandable history, OG tags, JSON API
All checks were successful
ci / check (push) Successful in 46s

This commit is contained in:
Jean Chevronnet 2026-07-30 01:27:39 +00:00
parent 26496b5147
commit 6af92c1217
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
7 changed files with 714 additions and 101 deletions

View file

@ -3,7 +3,7 @@ use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use rustbot::i18n::Locale;
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, Board};
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, render_json, Board};
fn main() {
let mut args = std::env::args().skip(1);
@ -40,9 +40,11 @@ fn main() {
}
let meta = p.with_file_name(format!("karma.{net}.meta.json"));
if let Ok(mbody) = fs::read_to_string(&meta) {
let (server, channels) = parse_meta(&mbody);
board.server = server;
board.channels = channels;
let m = parse_meta(&mbody);
board.server = m.server;
board.webchat = m.webchat;
board.seen = m.seen;
board.channels = m.channels;
}
boards.push(board);
}
@ -58,12 +60,22 @@ fn main() {
match out {
Some(path) => {
let tmp = format!("{path}.tmp");
if let Err(e) = fs::write(&tmp, &html).and_then(|_| fs::rename(&tmp, &path)) {
if let Err(e) = write_atomic(&path, &html) {
eprintln!("karma-web: could not write {path}: {e}");
std::process::exit(1);
}
// emit the JSON API next to the page
if let Some(dir) = PathBuf::from(&path).parent() {
let json_path = dir.join("karma.json").to_string_lossy().into_owned();
let _ = write_atomic(&json_path, &render_json(&boards, epoch));
}
}
None => print!("{html}"),
}
}
fn write_atomic(path: &str, body: &str) -> std::io::Result<()> {
let tmp = format!("{path}.tmp");
fs::write(&tmp, body)?;
fs::rename(&tmp, path)
}

View file

@ -23,6 +23,8 @@ pub struct Bot {
modules: Vec<Box<dyn Module>>,
routes: HashMap<&'static str, usize>,
topics: HashMap<String, String>,
users: HashMap<String, u32>,
names_building: HashMap<String, u32>,
}
impl Bot {
@ -59,28 +61,44 @@ impl Bot {
modules: mods,
routes,
topics: HashMap::new(),
users: HashMap::new(),
names_building: HashMap::new(),
};
bot.write_source_meta();
bot
}
/// Rewrite the per-network source sidecar the web board reads: server,
/// the configured channels, and whatever topics we've learned so far.
/// webchat base, a liveness timestamp, and per-channel topic + user count.
fn write_source_meta(&self) {
let channels: Vec<(String, Option<String>)> = self
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let channels: Vec<String> = self
.config
.channels
.iter()
.map(|c| {
let topic = self
.topics
.get(&c.to_lowercase())
.filter(|t| !t.is_empty())
.cloned();
(c.clone(), topic)
let key = c.to_lowercase();
let mut obj = format!("{{\"name\":\"{}\"", crate::json::escape(c));
if let Some(t) = self.topics.get(&key).filter(|t| !t.is_empty()) {
obj.push_str(&format!(",\"topic\":\"{}\"", crate::json::escape(t)));
}
if let Some(u) = self.users.get(&key) {
obj.push_str(&format!(",\"users\":{u}"));
}
obj.push('}');
obj
})
.collect();
modules::karma::write_meta(&self.config.name, &self.config.server, &channels);
let body = format!(
"{{\"server\":\"{}\",\"webchat\":\"{}\",\"seen\":{now},\"channels\":[{}]}}\n",
crate::json::escape(&self.config.server),
crate::json::escape(&self.config.webchat),
channels.join(",")
);
modules::karma::write_meta(&self.config.name, &body);
}
fn set_topic(&mut self, channel: Option<&str>, topic: Option<&str>) {
@ -117,6 +135,8 @@ impl Bot {
"PING" => {
let token = msg.trailing().unwrap_or("").to_string();
self.conn.pong(&token)?;
// heartbeat: refresh the sidecar's liveness timestamp
self.write_source_meta();
}
"CAP" => self.handle_cap(msg)?,
"AUTHENTICATE" => self.handle_authenticate(msg)?,
@ -140,6 +160,22 @@ impl Bot {
"331" => self.set_topic(msg.param(1), Some("")),
// live topic change: :who TOPIC <channel> :<topic>
"TOPIC" => self.set_topic(msg.param(0), msg.trailing()),
// RPL_NAMREPLY: <nick> <sym> <channel> :n1 n2 n3 (may repeat)
"353" => {
if let (Some(chan), Some(names)) = (msg.param(2), msg.trailing()) {
let n = names.split_whitespace().count() as u32;
*self.names_building.entry(chan.to_lowercase()).or_insert(0) += n;
}
}
// RPL_ENDOFNAMES: finalize the count for this channel
"366" => {
if let Some(chan) = msg.param(1) {
let key = chan.to_lowercase();
let count = self.names_building.remove(&key).unwrap_or(0);
self.users.insert(key, count);
self.write_source_meta();
}
}
"PRIVMSG" => self.handle_privmsg(msg)?,
_ => {}
}

View file

@ -438,23 +438,9 @@ fn data_path(network: &str, ext: &str) -> PathBuf {
/// Record where a network's karma comes from (server + channels) so the web
/// page can show its source. Best-effort — a failure just omits the line.
pub fn write_meta(network: &str, server: &str, channels: &[(String, Option<String>)]) {
let chans = channels
.iter()
.map(|(name, topic)| match topic {
Some(t) => format!(
"{{\"name\":\"{}\",\"topic\":\"{}\"}}",
json::escape(name),
json::escape(t)
),
None => format!("{{\"name\":\"{}\"}}", json::escape(name)),
})
.collect::<Vec<_>>()
.join(",");
let body = format!(
"{{\"server\":\"{}\",\"channels\":[{chans}]}}\n",
json::escape(server)
);
/// Atomically write the per-network source sidecar the web board reads. The
/// caller (`Bot`) owns the JSON schema (server, webchat, seen, channels...).
pub fn write_meta(network: &str, body: &str) {
let path = data_path(network, "meta.json");
let mut tmp = path.clone().into_os_string();
tmp.push(".tmp");

View file

@ -20,6 +20,7 @@ pub struct Config {
pub sasl_pass: Option<String>,
pub locale: String,
pub karma_url: String,
pub webchat: String,
}
impl Default for Config {
@ -40,6 +41,7 @@ impl Default for Config {
sasl_pass: None,
locale: "en".to_string(),
karma_url: String::new(),
webchat: String::new(),
}
}
}
@ -166,6 +168,7 @@ fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) {
"sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()),
"locale" | "lang" => c.locale = value.to_string(),
"karma_url" | "web_url" => c.karma_url = value.to_string(),
"webchat" => c.webchat = value.to_string(),
other => log(&format!("{where_}: unknown key '{other}'")),
}
}
@ -229,6 +232,9 @@ fn apply_env(c: &mut Config) {
if let Ok(v) = std::env::var("IRC_KARMA_URL") {
c.karma_url = v;
}
if let Ok(v) = std::env::var("IRC_WEBCHAT") {
c.webchat = v;
}
}
fn parse_bool(value: &str) -> bool {

View file

@ -21,6 +21,14 @@ pub struct Event {
pub struct Channel {
pub name: String,
pub topic: Option<String>,
pub users: Option<u32>,
}
pub struct Meta {
pub server: String,
pub webchat: String,
pub seen: u64,
pub channels: Vec<Channel>,
}
pub struct Board {
@ -28,6 +36,8 @@ pub struct Board {
pub rows: Vec<Row>,
pub events: Vec<Event>,
pub server: String,
pub webchat: String,
pub seen: u64,
pub channels: Vec<Channel>,
}
@ -54,6 +64,8 @@ pub fn parse_board(network: &str, body: &str) -> Board {
rows,
events: Vec::new(),
server: String::new(),
webchat: String::new(),
seen: 0,
channels: Vec::new(),
}
}
@ -61,15 +73,17 @@ pub fn parse_board(network: &str, body: &str) -> Board {
/// Parse the `karma.<net>.meta.json` sidecar the bot writes: where this
/// network's karma comes from. Channels may be bare strings (old format) or
/// `{"name","topic"}` objects.
pub fn parse_meta(body: &str) -> (String, Vec<Channel>) {
let Some(j) = Json::parse(body) else {
return (String::new(), Vec::new());
pub fn parse_meta(body: &str) -> Meta {
let empty = || Meta {
server: String::new(),
webchat: String::new(),
seen: 0,
channels: Vec::new(),
};
let server = j
.get("server")
.and_then(Json::as_str)
.unwrap_or("")
.to_string();
let Some(j) = Json::parse(body) else {
return empty();
};
let string = |k: &str| j.get(k).and_then(Json::as_str).unwrap_or("").to_string();
let channels = match j.get("channels") {
Some(Json::Arr(items)) => items
.iter()
@ -78,6 +92,7 @@ pub fn parse_meta(body: &str) -> (String, Vec<Channel>) {
return (!name.is_empty()).then(|| Channel {
name: name.to_string(),
topic: None,
users: None,
});
}
let name = it.get("name").and_then(Json::as_str)?;
@ -89,15 +104,22 @@ pub fn parse_meta(body: &str) -> (String, Vec<Channel>) {
.and_then(Json::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
let users = it.get("users").and_then(Json::as_f64).map(|n| n as u32);
Some(Channel {
name: name.to_string(),
topic,
users,
})
})
.collect(),
_ => Vec::new(),
};
(server, channels)
Meta {
server: string("server"),
webchat: string("webchat"),
seen: j.get("seen").and_then(Json::as_f64).unwrap_or(0.0) as u64,
channels,
}
}
pub fn parse_events(body: &str) -> Vec<Event> {
@ -328,6 +350,20 @@ struct Lex {
give: &'static str,
built: &'static str,
updated: &'static str,
online: &'static str,
offline: &'static str,
rising: &'static str,
over_time: &'static str,
most_debated: &'static str,
milestones: &'static str,
reasons_recent: &'static str,
history: &'static str,
reached: &'static str,
bumps: &'static str,
filter_ph: &'static str,
sort_karma: &'static str,
sort_name: &'static str,
sort_active: &'static str,
}
fn lex(l: Locale) -> Lex {
@ -353,6 +389,20 @@ fn lex(l: Locale) -> Lex {
give: "give karma by saying <code>nick++</code> in the channel",
built: "built by",
updated: "updated",
online: "online",
offline: "offline",
rising: "rising this week",
over_time: "karma over time",
most_debated: "most debated",
milestones: "milestones",
reasons_recent: "recent reasons",
history: "history",
reached: "reached",
bumps: "bumps",
filter_ph: "filter…",
sort_karma: "karma",
sort_name: "name",
sort_active: "active",
},
Locale::Es => Lex {
tagline: "reputación ganada de a un <code>nick++</code>, en todos los canales.",
@ -375,6 +425,20 @@ fn lex(l: Locale) -> Lex {
give: "da karma escribiendo <code>nick++</code> en el canal",
built: "hecho con",
updated: "actualizado",
online: "en línea",
offline: "desconectado",
rising: "en alza esta semana",
over_time: "karma en el tiempo",
most_debated: "más debatidos",
milestones: "hitos",
reasons_recent: "razones recientes",
history: "historial",
reached: "llegó a",
bumps: "cambios",
filter_ph: "filtrar…",
sort_karma: "karma",
sort_name: "nombre",
sort_active: "activos",
},
Locale::Fr => Lex {
tagline: "la réputation gagnée un <code>nick++</code> à la fois, sur les canaux.",
@ -397,6 +461,20 @@ fn lex(l: Locale) -> Lex {
give: "donne du karma en disant <code>nick++</code> dans le canal",
built: "fait avec",
updated: "mis à jour",
online: "en ligne",
offline: "hors ligne",
rising: "en hausse cette semaine",
over_time: "karma dans le temps",
most_debated: "les plus débattus",
milestones: "jalons",
reasons_recent: "raisons récentes",
history: "historique",
reached: "a atteint",
bumps: "changements",
filter_ph: "filtrer…",
sort_karma: "karma",
sort_name: "nom",
sort_active: "actifs",
},
}
}
@ -417,7 +495,10 @@ pub fn render(boards: &[Board], updated_epoch: u64, locale: Locale) -> String {
.collect();
let gifts = feed.len();
let mut s = String::from(HEAD);
let now = updated_epoch;
let mut s = HEAD
.replace("__LANG__", locale.code())
.replace("__OG__", &og_tags(boards, &lx));
let multi = boards.iter().filter(|b| !b.rows.is_empty()).count() > 1;
s.push_str("<header class=\"hero fade\"><div class=\"prompt\">rubot@devtronic</div>");
@ -443,20 +524,102 @@ pub fn render(boards: &[Board], updated_epoch: u64, locale: Locale) -> String {
continue;
}
s.push_str("<section class=\"board fade\">");
s.push_str(&source_line(b, &lx));
s.push_str(&podium(b));
s.push_str(&source_line(b, now, &lx));
s.push_str(&podium(b, &lx));
s.push_str(&ledger(b, &lx));
s.push_str("</section>");
}
s.push_str(&rising(&feed, now, &lx));
s.push_str(&over_time(&feed, now, &lx));
s.push_str("<div class=\"band\">");
s.push_str(&activity(&feed, multi, &lx));
s.push_str(&givers_panel(&feed, &lx));
s.push_str("</div>");
s.push_str("<div class=\"band3\">");
s.push_str(&most_debated(&feed, &lx));
s.push_str(&milestones(&feed, &lx));
s.push_str(&reasons_wall(&feed, &lx));
s.push_str("</div>");
finish(s, updated_epoch, locale, &lx)
}
/// A machine-readable snapshot of the board(s) — served alongside the page as
/// `karma.json` so other tools/bots can read the data.
pub fn render_json(boards: &[Board], updated_epoch: u64) -> String {
let nets: Vec<String> = boards
.iter()
.map(|b| {
let channels: Vec<String> = b
.channels
.iter()
.map(|c| {
let mut o = format!("{{\"name\":\"{}\"", json::escape(&c.name));
if let Some(t) = &c.topic {
o.push_str(&format!(",\"topic\":\"{}\"", json::escape(t)));
}
if let Some(u) = c.users {
o.push_str(&format!(",\"users\":{u}"));
}
o.push('}');
o
})
.collect();
let things: Vec<String> = b
.rows
.iter()
.map(|r| {
let mut o = format!(
"{{\"thing\":\"{}\",\"karma\":{}",
json::escape(&r.thing),
r.count
);
if let Some(w) = &r.reason {
o.push_str(&format!(",\"reason\":\"{}\"", json::escape(w)));
}
if let Some(by) = &r.by {
o.push_str(&format!(",\"by\":\"{}\"", json::escape(by)));
}
o.push('}');
o
})
.collect();
format!(
"{{\"network\":\"{}\",\"server\":\"{}\",\"channels\":[{}],\"things\":[{}]}}",
json::escape(&b.network),
json::escape(&b.server),
channels.join(","),
things.join(",")
)
})
.collect();
format!(
"{{\"generated\":{updated_epoch},\"networks\":[{}]}}\n",
nets.join(",")
)
}
fn og_tags(boards: &[Board], lx: &Lex) -> String {
let things: usize = boards.iter().map(|b| b.rows.len()).sum();
let top = boards
.iter()
.flat_map(|b| &b.rows)
.max_by_key(|r| r.count)
.map(|r| format!(" · {} {}", esc(&r.thing), signed(r.count)))
.unwrap_or_default();
let desc = format!("{things} {}{top}", lx.s_tracked);
format!(
"<meta property=\"og:type\" content=\"website\">\
<meta property=\"og:title\" content=\"karma · devtronic\">\
<meta property=\"og:description\" content=\"{desc}\">\
<meta name=\"twitter:card\" content=\"summary\">\
<meta name=\"description\" content=\"{desc}\">"
)
}
fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String {
s.push_str(&footer(epoch, lx));
s.push_str("<script>var C=");
@ -469,13 +632,29 @@ fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String {
}
/// "from #argentina · irc.libera.chat" — where this board's karma comes from.
fn source_line(b: &Board, lx: &Lex) -> String {
fn source_line(b: &Board, now: u64, lx: &Lex) -> String {
let online = b.seen > 0 && now.saturating_sub(b.seen) < 300;
let dot = format!(
"<span class=\"dot {}\" title=\"{}\"></span>",
if online { "on" } else { "off" },
if online { lx.online } else { lx.offline }
);
let mut parts: Vec<String> = Vec::new();
if !b.channels.is_empty() {
let chans = b
.channels
.iter()
.map(|c| esc(&c.name))
.map(|c| {
let users = match c.users {
Some(u) => format!(" <span class=\"users\">{u} {}</span>", lx.online),
None => String::new(),
};
format!(
"<a class=\"chan\" href=\"{}\" rel=\"noopener\" target=\"_blank\">{}</a>{users}",
join_url(b, &c.name),
esc(&c.name)
)
})
.collect::<Vec<_>>()
.join(" ");
parts.push(format!("<span class=\"chans\">{chans}</span>"));
@ -487,7 +666,7 @@ fn source_line(b: &Board, lx: &Lex) -> String {
parts.push(format!("<span class=\"host\">{}</span>", esc(&b.network)));
}
let mut out = format!(
"<div class=\"source\"><span class=\"lbl\">{}</span>{}</div>",
"<div class=\"source\">{dot}<span class=\"lbl\">{}</span>{}</div>",
lx.from,
parts.join("<span class=\"sep\">·</span>")
);
@ -495,6 +674,19 @@ fn source_line(b: &Board, lx: &Lex) -> String {
out
}
/// A clickable join target for a channel: the configured webchat, else an
/// `ircs://` URL. Returned already escaped for use in an `href`.
fn join_url(b: &Board, channel: &str) -> String {
let raw = if !b.webchat.is_empty() {
format!("{}{}", b.webchat, channel)
} else if !b.server.is_empty() {
format!("ircs://{}/{}", b.server, channel.replace('#', "%23"))
} else {
String::new()
};
esc(&raw)
}
fn topics_block(b: &Board) -> String {
let multi = b.channels.len() > 1;
let items: Vec<String> = b
@ -544,7 +736,52 @@ fn stat(num: &str, label: &str) -> String {
)
}
fn podium(b: &Board) -> String {
fn hist_html(thing: &str, events: &[Event], lx: &Lex) -> String {
let key = thing.to_lowercase();
let mut evs: Vec<&Event> = events
.iter()
.filter(|e| e.thing.to_lowercase() == key)
.collect();
if evs.is_empty() {
return String::new();
}
evs.sort_by_key(|e| std::cmp::Reverse(e.t));
let items: String = evs
.iter()
.take(20)
.map(|e| {
let by = e.by.clone().unwrap_or_else(|| lx.someone.to_string());
let chip = if e.delta >= 0 { "++" } else { "--" };
let why = match &e.why {
Some(w) => format!(" <span class=\"why\">\u{201C}{}\u{201D}</span>", esc(w)),
None => String::new(),
};
format!(
"<li class=\"hev\"><span class=\"chip {kl}\">{chip}</span> <b>{}</b>{why} \
<time class=\"ago\" data-t=\"{}\"></time></li>",
esc(&by),
e.t,
kl = klass(e.delta),
)
})
.collect();
format!("<ol class=\"hist\" hidden>{items}</ol>")
}
/// `(class-suffix, title-attr)` marking an element expandable when it has a
/// non-empty history block.
fn expand_attrs(hist: &str, lx: &Lex) -> (String, String) {
if hist.is_empty() {
(String::new(), String::new())
} else {
(
" expand".to_string(),
format!(" title=\"{}\"", esc(lx.history)),
)
}
}
fn podium(b: &Board, lx: &Lex) -> String {
let top = b.rows.iter().take(3).collect::<Vec<_>>();
let mut out = String::from("<ol class=\"podium\">");
for (i, r) in top.iter().enumerate() {
@ -565,9 +802,11 @@ fn podium(b: &Board) -> String {
(Some(why), None) => format!("<div class=\"why\">\u{201C}{}\u{201D}</div>", esc(why)),
_ => String::new(),
};
let hist = hist_html(&r.thing, &b.events, lx);
let (expand, title) = expand_attrs(&hist, lx);
out.push_str(&format!(
"<li class=\"pillar r{rank}\"><div class=\"crown\">{crown}</div>{}\
<div class=\"nick\">{}</div><div class=\"score {}\">{}</div>{spark}{reason}</li>",
"<li class=\"pillar r{rank}{expand}\"{title}><div class=\"crown\">{crown}</div>{}\
<div class=\"nick\">{}</div><div class=\"score {}\">{}</div>{spark}{reason}{hist}</li>",
avatar(&r.thing, "lg"),
esc(&r.thing),
klass(r.count),
@ -582,8 +821,19 @@ fn ledger(b: &Board, lx: &Lex) -> String {
if b.rows.len() <= 3 {
return String::new();
}
let controls = format!(
"<div class=\"controls\">\
<input id=\"q\" class=\"filter\" type=\"search\" placeholder=\"{ph}\" autocomplete=\"off\" aria-label=\"{ph}\">\
<div class=\"sorts\"><button type=\"button\" data-sort=\"score\" class=\"on\">{}</button>\
<button type=\"button\" data-sort=\"name\">{}</button>\
<button type=\"button\" data-sort=\"active\">{}</button></div></div>",
lx.sort_karma,
lx.sort_name,
lx.sort_active,
ph = esc(lx.filter_ph),
);
let mut out = format!(
"<div class=\"eyebrow\">{}</div><ol class=\"list\">",
"<div class=\"eyebrow\">{}</div>{controls}<ol class=\"list ledger\">",
lx.ledger
);
for (i, r) in b.rows.iter().enumerate().skip(3) {
@ -598,14 +848,25 @@ fn ledger(b: &Board, lx: &Lex) -> String {
(Some(why), None) => format!("<span class=\"why\">\u{201C}{}\u{201D}</span>", esc(why)),
_ => String::new(),
};
let key = r.thing.to_lowercase();
let active = b
.events
.iter()
.filter(|e| e.thing.to_lowercase() == key)
.count();
let hist = hist_html(&r.thing, &b.events, lx);
let (expand, title) = expand_attrs(&hist, lx);
out.push_str(&format!(
"<li class=\"row\"><span class=\"rank\">{rank}</span>{}\
<div class=\"who\"><div class=\"nick\">{}</div>{reason}</div>\
{spark}<span class=\"score {}\">{}</span></li>",
avatar(&r.thing, "sm"),
esc(&r.thing),
klass(r.count),
signed(r.count),
"<li class=\"row thing{expand}\"{title} data-name=\"{namel}\" data-score=\"{score}\" data-active=\"{active}\">\
<span class=\"rank\">{rank}</span>{av}\
<div class=\"who\"><div class=\"nick\">{name}</div>{reason}</div>\
{spark}<span class=\"score {kl}\">{sg}</span>{hist}</li>",
namel = esc(&key),
score = r.count,
av = avatar(&r.thing, "sm"),
name = esc(&r.thing),
kl = klass(r.count),
sg = signed(r.count),
));
}
out.push_str("</ol>");
@ -693,6 +954,182 @@ fn givers_panel(feed: &[(&str, &Event)], lx: &Lex) -> String {
out
}
fn rising(feed: &[(&str, &Event)], now: u64, lx: &Lex) -> String {
let week = now.saturating_sub(7 * 86400);
let mut m: HashMap<String, (String, i64)> = HashMap::new();
for (_, e) in feed {
if e.t >= week {
let ent = m
.entry(e.thing.to_lowercase())
.or_insert((e.thing.clone(), 0));
ent.1 += e.delta;
}
}
let mut v: Vec<(String, i64)> = m.into_values().filter(|(_, d)| *d != 0).collect();
if v.is_empty() {
return String::new();
}
v.sort_by(|a, b| {
b.1.abs()
.cmp(&a.1.abs())
.then(a.0.to_lowercase().cmp(&b.0.to_lowercase()))
});
v.truncate(8);
let chips: String = v
.iter()
.map(|(name, d)| {
format!(
"<span class=\"tchip {}\">{} <b>{}</b></span>",
klass(*d),
esc(name),
signed(*d)
)
})
.collect();
format!(
"<section class=\"strip fade\"><div class=\"eyebrow\">🔥 {}</div><div class=\"chips\">{chips}</div></section>",
lx.rising
)
}
fn over_time(feed: &[(&str, &Event)], now: u64, lx: &Lex) -> String {
const DAYS: usize = 30;
let day = 86400u64;
let start = now.saturating_sub((DAYS as u64 - 1) * day);
let mut buckets = [0u32; DAYS];
for (_, e) in feed {
if e.t >= start {
let idx = ((e.t - start) / day) as usize;
if idx < DAYS {
buckets[idx] += 1;
}
}
}
let max = *buckets.iter().max().unwrap_or(&0);
if max == 0 {
return String::new();
}
let bars: String = buckets
.iter()
.map(|c| {
let h = (*c as f64 / max as f64 * 100.0).round() as u32;
format!(
"<span class=\"bar\" style=\"height:{}%\" title=\"{c}\"></span>",
h.max(3)
)
})
.collect();
let total: u32 = buckets.iter().sum();
format!(
"<section class=\"chart fade\"><div class=\"eyebrow\">{} \
<span class=\"count\">{total} · {DAYS}d</span></div><div class=\"bars\">{bars}</div></section>",
lx.over_time
)
}
fn most_debated(feed: &[(&str, &Event)], lx: &Lex) -> String {
let mut m: HashMap<String, (String, i64, i64)> = HashMap::new();
for (_, e) in feed {
let ent = m
.entry(e.thing.to_lowercase())
.or_insert((e.thing.clone(), 0, 0));
ent.1 += 1;
ent.2 += e.delta;
}
let mut v: Vec<(String, i64, i64)> = m.into_values().collect();
if v.is_empty() {
return String::new();
}
v.sort_by(|a, b| {
b.1.cmp(&a.1)
.then(a.0.to_lowercase().cmp(&b.0.to_lowercase()))
});
v.truncate(6);
let items: String = v
.iter()
.map(|(name, bumps, net)| {
format!(
"<li class=\"row\"><div class=\"who\"><div class=\"nick\">{}</div>\
<span class=\"why\">{bumps} {}</span></div><span class=\"score {}\">{}</span></li>",
esc(name),
lx.bumps,
klass(*net),
signed(*net)
)
})
.collect();
format!(
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div><ol class=\"list\">{items}</ol></section>",
lx.most_debated
)
}
fn milestones(feed: &[(&str, &Event)], lx: &Lex) -> String {
const LEVELS: &[i64] = &[10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
let mut evs: Vec<&(&str, &Event)> = feed.iter().collect();
evs.sort_by_key(|(_, e)| e.t);
let mut cum: HashMap<String, i64> = HashMap::new();
let mut hits: Vec<(String, i64, u64)> = Vec::new();
for (_, e) in evs {
let c = cum.entry(e.thing.to_lowercase()).or_insert(0);
let before = *c;
*c += e.delta;
let after = *c;
for &lv in LEVELS {
if before < lv && after >= lv {
hits.push((e.thing.clone(), lv, e.t));
}
}
}
if hits.is_empty() {
return String::new();
}
hits.sort_by_key(|(_, _, t)| std::cmp::Reverse(*t));
hits.truncate(8);
let items: String = hits
.iter()
.map(|(name, lv, t)| {
format!(
"<li class=\"hev\">🎉 <b>{}</b> {} <b>{lv}</b> \
<time class=\"ago\" data-t=\"{t}\"></time></li>",
esc(name),
lx.reached
)
})
.collect();
format!(
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div><ol class=\"list\">{items}</ol></section>",
lx.milestones
)
}
fn reasons_wall(feed: &[(&str, &Event)], lx: &Lex) -> String {
let items: String = feed
.iter()
.filter(|(_, e)| e.why.is_some())
.take(6)
.map(|(_, e)| {
let by = e.by.clone().unwrap_or_else(|| lx.someone.to_string());
format!(
"<li class=\"quote\"><div class=\"q\">\u{201C}{}\u{201D}</div>\
<div class=\"qm\"><b>{}</b> \u{2192} <b>{}</b> \
<time class=\"ago\" data-t=\"{}\"></time></div></li>",
esc(e.why.as_deref().unwrap_or("")),
esc(&by),
esc(&e.thing),
e.t
)
})
.collect();
if items.is_empty() {
return String::new();
}
format!(
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div><ul class=\"quotes\">{items}</ul></section>",
lx.reasons_recent
)
}
fn footer(epoch: u64, lx: &Lex) -> String {
format!(
"<footer class=\"fade\">{} &middot; {} \
@ -732,11 +1169,12 @@ fn time_cfg(l: Locale) -> String {
}
const HEAD: &str = r##"<!doctype html>
<html lang="en">
<html lang="__LANG__">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>karma · devtronic</title>
__OG__
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='13' font-size='13'%3E%E2%9C%A8%3C/text%3E%3C/svg%3E">
<style>
:root{
@ -844,6 +1282,41 @@ border-radius:6px;padding:0 6px;margin-left:2px}
.ago{display:block;color:var(--faint);font-size:11.5px;margin-top:2px;font-variant-numeric:tabular-nums}
footer{text-align:center;color:var(--muted);font-size:12.5px;margin-top:44px;line-height:2}
.dot{width:9px;height:9px;border-radius:50%;flex:none}
.dot.on{background:var(--up);box-shadow:0 0 0 3px rgba(95,211,159,.16)}
.dot.off{background:var(--faint)}
.source .chan{font-family:var(--mono);color:var(--gold);font-weight:600}
.source .chan:hover{text-decoration:underline}
.source .users{font-family:var(--mono);color:var(--faint);font-size:11px}
.strip{margin:30px 2px 6px}
.chips{display:flex;flex-wrap:wrap;gap:8px}
.tchip{font-family:var(--mono);font-size:13px;background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:5px 12px}
.tchip.up{color:var(--up)}.tchip.down{color:var(--down)}
.tchip b{font-weight:700}
.chart{margin:24px 2px 6px}
.chart .eyebrow{margin:0 0 10px}
.chart .count{margin-left:auto;color:var(--faint);font-family:var(--mono);font-size:11px}
.bars{display:flex;align-items:flex-end;gap:3px;height:64px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px}
.bar{flex:1;background:linear-gradient(180deg,var(--gold),rgba(245,182,66,.3));border-radius:2px;min-height:2px}
.band3{display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px}
@media(min-width:720px){.band3{grid-template-columns:1fr 1fr 1fr}}
.quotes{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:11px}
.quote .q{color:var(--fg);font-size:13.5px;line-height:1.5}
.quote .qm{color:var(--muted);font-size:12px;margin-top:2px}
.quote .qm b{font-family:var(--mono);font-weight:600}
.controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 2px 12px}
.filter{flex:1;min-width:150px;padding:9px 12px;border-radius:10px;border:1px solid var(--line);background:var(--panel);color:var(--fg);font-size:14px}
.filter:focus{outline:none;border-color:var(--gold)}
.sorts{display:flex;gap:6px}
.sorts button{font:inherit;font-size:12px;color:var(--muted);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:7px 11px;cursor:pointer}
.sorts button.on{color:var(--bg);background:var(--gold);border-color:var(--gold);font-weight:600}
.row.thing,.pillar.expand{cursor:pointer}
.row.thing{flex-wrap:wrap}
.hist{list-style:none;margin:10px 0 0;padding:10px 0 0;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px;flex-basis:100%;width:100%}
.pillar .hist{text-align:left}
.hev{font-size:12.5px;color:var(--muted);word-break:break-word}
.hev b{font-family:var(--mono);color:var(--fg);font-weight:600}
.hev .chip{font-size:11px;padding:0 5px}
.empty{text-align:center;color:var(--muted);padding:56px 10px}
@media(max-width:560px){
@ -878,6 +1351,38 @@ return C.pre+d+C.u[6]+C.post;
document.querySelectorAll("time.ago[data-t]").forEach(function(t){
var v=parseInt(t.getAttribute("data-t"),10);if(v)t.textContent=ago(v);
});
// click a row/pillar to toggle its history
document.querySelectorAll(".row.thing,.pillar.expand").forEach(function(el){
el.addEventListener("click",function(e){
if(e.target.closest("a"))return;
var h=el.querySelector(".hist");if(h)h.hidden=!h.hidden;
});
});
// filter the ledger
var q=document.getElementById("q");
if(q){q.addEventListener("input",function(){
var t=q.value.toLowerCase();
document.querySelectorAll("ol.ledger>li.row").forEach(function(li){
li.style.display=li.textContent.toLowerCase().indexOf(t)>=0?"":"none";
});
});}
// sort the ledger
document.querySelectorAll(".sorts button").forEach(function(btn){
btn.addEventListener("click",function(){
var key=btn.getAttribute("data-sort");
var list=document.querySelector("ol.ledger");if(!list)return;
document.querySelectorAll(".sorts button").forEach(function(b){b.classList.remove("on");});
btn.classList.add("on");
var rows=Array.prototype.slice.call(list.querySelectorAll("li.row"));
rows.sort(function(a,b){
if(key=="name")return a.getAttribute("data-name").localeCompare(b.getAttribute("data-name"));
var an=parseInt(a.getAttribute(key=="active"?"data-active":"data-score"),10);
var bn=parseInt(b.getAttribute(key=="active"?"data-active":"data-score"),10);
return bn-an;
});
rows.forEach(function(r){list.appendChild(r);});
});
});
setTimeout(function(){location.reload();},60000);
})();"##;