karma: log bump events; rebuild web page with podium, sparklines, activity feed and givers
All checks were successful
ci / check (push) Successful in 44s

This commit is contained in:
Jean Chevronnet 2026-07-29 23:45:29 +00:00
parent 9a3393153a
commit f290554325
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
7 changed files with 710 additions and 141 deletions

1
.gitignore vendored
View file

@ -4,6 +4,7 @@ rustbot.service
rustbot.conf rustbot.conf
weather.*.json weather.*.json
karma.*.json karma.*.json
karma.*.events.jsonl
*.json.tmp *.json.tmp
karma-web.service karma-web.service
karma-web.timer karma-web.timer

View file

@ -79,10 +79,13 @@ lists them at runtime and `help <command>` describes one.
## Karma web page ## Karma web page
The live karma board is published at **<https://karma.devtronic.pro>**. A second The live karma board is published at **<https://karma.devtronic.pro>** — a hero
binary, `karma-web`, reads the per-network `karma.*.json` files and renders one with live stats, a top-3 podium, a ranked ledger with per-thing trend
self-contained HTML page (no JS/CSS/font dependencies, all IRC-supplied text sparklines, a recent-activity feed, and a top-givers board. A second binary,
HTML-escaped); a systemd timer regenerates it every minute and nginx serves it `karma-web`, reads the per-network `karma.*.json` totals plus the append-only
`karma.*.events.jsonl` history the bot writes on every bump, and renders one
self-contained HTML page (no CSS/JS/font dependencies, all IRC-supplied text
HTML-escaped). A systemd timer regenerates it every minute and nginx serves it
over TLS. over TLS.
```sh ```sh

View file

@ -2,7 +2,7 @@ use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use rustbot::karma_web::{parse_board, render, Board}; use rustbot::karma_web::{parse_board, parse_events, render, Board};
fn main() { fn main() {
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
@ -23,7 +23,12 @@ fn main() {
.and_then(|s| s.strip_suffix(".json")) .and_then(|s| s.strip_suffix(".json"))
{ {
if let Ok(body) = fs::read_to_string(&p) { if let Ok(body) = fs::read_to_string(&p) {
boards.push(parse_board(net, &body)); let mut board = parse_board(net, &body);
let ev = p.with_file_name(format!("karma.{net}.events.jsonl"));
if let Ok(evbody) = fs::read_to_string(&ev) {
board.events = parse_events(&evbody);
}
boards.push(board);
} }
} }
} }

View file

@ -1,6 +1,9 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::path::PathBuf; use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::bot::module::{Action, Chat, Command, CommandSpec, Module}; use crate::bot::module::{Action, Chat, Command, CommandSpec, Module};
use crate::json::{self, Json}; use crate::json::{self, Json};
@ -112,6 +115,7 @@ impl Module for Karma {
} }
// A reason is only attributed when the message has a single bump. // A reason is only attributed when the message has a single bump.
let reason = extract_reason(chat.text); let reason = extract_reason(chat.text);
let t = now();
let mut announces = Vec::new(); let mut announces = Vec::new();
for (thing, delta) in &changes { for (thing, delta) in &changes {
let r = if changes.len() == 1 { let r = if changes.len() == 1 {
@ -119,6 +123,8 @@ impl Module for Karma {
} else { } else {
None None
}; };
self.store
.log_event(&format_event(t, thing, *delta, chat.sender, r));
let new = self.store.bump(thing, *delta, r, chat.sender); let new = self.store.bump(thing, *delta, r, chat.sender);
if *delta > 0 && MILESTONES.contains(&new) { if *delta > 0 && MILESTONES.contains(&new) {
announces.push(Action::reply(format!( announces.push(Action::reply(format!(
@ -221,6 +227,7 @@ struct Entry {
struct Store { struct Store {
map: HashMap<String, Entry>, map: HashMap<String, Entry>,
path: PathBuf, path: PathBuf,
events_path: PathBuf,
} }
impl Store { impl Store {
@ -238,7 +245,22 @@ impl Store {
m m
}) })
.unwrap_or_default(); .unwrap_or_default();
Store { map, path } let events_path = events_path_for(&path);
Store {
map,
path,
events_path,
}
}
fn log_event(&self, line: &str) {
if let Ok(mut f) = OpenOptions::new()
.create(true)
.append(true)
.open(&self.events_path)
{
let _ = f.write_all(line.as_bytes());
}
} }
fn save(&self) -> std::io::Result<()> { fn save(&self) -> std::io::Result<()> {
@ -323,3 +345,28 @@ fn entry_from_json(v: &Json) -> Entry {
.map(str::to_string), .map(str::to_string),
} }
} }
fn events_path_for(path: &Path) -> PathBuf {
let p = path.to_string_lossy();
let base = p.strip_suffix(".json").unwrap_or(&p);
PathBuf::from(format!("{base}.events.jsonl"))
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn format_event(t: u64, thing: &str, delta: i64, by: &str, why: Option<&str>) -> String {
let why_field = match why {
Some(w) if !w.is_empty() => format!(",\"why\":\"{}\"", json::escape(w)),
_ => String::new(),
};
format!(
"{{\"t\":{t},\"o\":\"{}\",\"d\":{delta},\"by\":\"{}\"{why_field}}}\n",
json::escape(thing),
json::escape(by)
)
}

View file

@ -1,3 +1,5 @@
use std::collections::{HashMap, HashSet};
use crate::json::Json; use crate::json::Json;
pub struct Row { pub struct Row {
@ -7,9 +9,18 @@ pub struct Row {
pub by: Option<String>, pub by: Option<String>,
} }
pub struct Event {
pub t: u64,
pub thing: String,
pub delta: i64,
pub by: Option<String>,
pub why: Option<String>,
}
pub struct Board { pub struct Board {
pub network: String, pub network: String,
pub rows: Vec<Row>, pub rows: Vec<Row>,
pub events: Vec<Event>,
} }
pub fn parse_board(network: &str, body: &str) -> Board { pub fn parse_board(network: &str, body: &str) -> Board {
@ -33,9 +44,41 @@ pub fn parse_board(network: &str, body: &str) -> Board {
Board { Board {
network: network.to_string(), network: network.to_string(),
rows, rows,
events: Vec::new(),
} }
} }
pub fn parse_events(body: &str) -> Vec<Event> {
let mut out = Vec::new();
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Some(j) = Json::parse(line) else { continue };
let thing = match j.get("o").and_then(Json::as_str) {
Some(s) if !s.is_empty() => s.to_string(),
_ => continue,
};
out.push(Event {
t: j.get("t").and_then(Json::as_f64).unwrap_or(0.0) as u64,
thing,
delta: j.get("d").and_then(Json::as_f64).unwrap_or(0.0) as i64,
by: j
.get("by")
.and_then(Json::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string),
why: j
.get("why")
.and_then(Json::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string),
});
}
out
}
fn fields(v: &Json) -> (i64, Option<String>, Option<String>) { fn fields(v: &Json) -> (i64, Option<String>, Option<String>) {
if let Some(n) = v.as_f64() { if let Some(n) = v.as_f64() {
return (n as i64, None, None); return (n as i64, None, None);
@ -69,70 +112,364 @@ fn esc(s: &str) -> String {
o o
} }
pub fn render(boards: &[Board], updated_epoch: u64) -> String { fn hue(s: &str) -> u32 {
let total: usize = boards.iter().map(|b| b.rows.len()).sum(); let mut h: u32 = 2166136261;
let mut s = String::from(HEAD); for b in s.to_lowercase().bytes() {
if total == 0 { h ^= b as u32;
s.push_str(EMPTY); h = h.wrapping_mul(16777619);
}
h % 360
}
fn initials(s: &str) -> String {
let up: String = s
.chars()
.filter(|c| c.is_alphanumeric())
.take(2)
.collect::<String>()
.to_uppercase();
if up.is_empty() {
"?".to_string()
} else { } else {
s.push_str(SEARCH); up
}
}
fn avatar(name: &str, size: &str) -> String {
format!(
"<span class=\"ava {size}\" style=\"--h:{}\" aria-hidden=\"true\">{}</span>",
hue(name),
esc(&initials(name))
)
}
fn signed(n: i64) -> String {
if n > 0 {
format!("+{n}")
} else {
n.to_string()
}
}
fn klass(n: i64) -> &'static str {
match n {
n if n > 0 => "up",
n if n < 0 => "down",
_ => "zero",
}
}
/// Cumulative karma over the logged events for `thing`, anchored so the last
/// point equals the current stored count. Empty if there is nothing to plot.
fn series_for(thing: &str, count: i64, events: &[Event]) -> Vec<i64> {
let key = thing.to_lowercase();
let deltas: Vec<i64> = events
.iter()
.filter(|e| e.thing.to_lowercase() == key)
.map(|e| e.delta)
.collect();
if deltas.is_empty() {
return Vec::new();
}
let sum: i64 = deltas.iter().sum();
let mut v = count - sum;
let mut series = Vec::with_capacity(deltas.len() + 1);
series.push(v);
for d in deltas {
v += d;
series.push(v);
}
series
}
fn sparkline(series: &[i64]) -> String {
if series.len() < 2 {
return String::new();
}
let (w, h, pad) = (104.0_f64, 30.0_f64, 4.0_f64);
let min = *series.iter().min().unwrap() as f64;
let max = *series.iter().max().unwrap() as f64;
let span = if (max - min).abs() < f64::EPSILON {
1.0
} else {
max - min
};
let n = series.len() as f64;
let at = |i: usize, v: i64| -> (f64, f64) {
let x = pad + (w - 2.0 * pad) * (i as f64) / (n - 1.0);
let y = pad + (h - 2.0 * pad) * (1.0 - (v as f64 - min) / span);
(x, y)
};
let mut pts = String::new();
for (i, v) in series.iter().enumerate() {
if i > 0 {
pts.push(' ');
}
let (x, y) = at(i, *v);
pts.push_str(&format!("{x:.1},{y:.1}"));
}
let (lx, ly) = at(series.len() - 1, *series.last().unwrap());
let cls = if series.last() >= series.first() {
"up"
} else {
"down"
};
format!(
"<svg class=\"spark {cls}\" viewBox=\"0 0 {w:.0} {h:.0}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\
<polyline points=\"{pts}\"/><circle cx=\"{lx:.1}\" cy=\"{ly:.1}\" r=\"2.2\"/></svg>"
)
}
struct Giver {
name: String,
gives: i64,
points: i64,
}
fn top_givers(all: &[(&str, &Event)], n: usize) -> Vec<Giver> {
let mut m: HashMap<String, (String, i64, i64)> = HashMap::new();
for (_, e) in all {
if let Some(by) = &e.by {
let ent = m.entry(by.to_lowercase()).or_insert((by.clone(), 0, 0));
ent.1 += 1;
ent.2 += e.delta;
}
}
let mut v: Vec<Giver> = m
.into_values()
.map(|(name, gives, points)| Giver {
name,
gives,
points,
})
.collect();
v.sort_by(|a, b| {
b.gives
.cmp(&a.gives)
.then(b.points.cmp(&a.points))
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
v.truncate(n);
v
}
pub fn render(boards: &[Board], updated_epoch: u64) -> String {
let things: usize = boards.iter().map(|b| b.rows.len()).sum();
let total_points: i64 = boards.iter().flat_map(|b| &b.rows).map(|r| r.count).sum();
let mut feed: Vec<(&str, &Event)> = boards
.iter()
.flat_map(|b| b.events.iter().map(move |e| (b.network.as_str(), e)))
.collect();
feed.sort_by_key(|(_, e)| std::cmp::Reverse(e.t));
let givers_set: HashSet<String> = feed
.iter()
.filter_map(|(_, e)| e.by.as_ref().map(|s| s.to_lowercase()))
.collect();
let gifts = feed.len();
let mut s = String::from(HEAD);
let multi = boards.iter().filter(|b| !b.rows.is_empty()).count() > 1;
// hero
s.push_str("<header class=\"hero fade\"><div class=\"prompt\">rubot@devtronic</div>");
s.push_str("<h1 class=\"mark\">karma<span class=\"cursor\">_</span></h1>");
s.push_str(
"<p class=\"tag\">reputation earned one <code>nick++</code> at a time, across the channels.</p>",
);
s.push_str("<div class=\"stats\">");
s.push_str(&stat(&things.to_string(), "tracked"));
s.push_str(&stat(&signed(total_points), "net karma"));
s.push_str(&stat(&gifts.to_string(), "gifts logged"));
s.push_str(&stat(&givers_set.len().to_string(), "givers"));
s.push_str("</div></header>");
if things == 0 {
s.push_str(EMPTY);
s.push_str(&footer(updated_epoch));
s.push_str(SCRIPT);
s.push_str(TAIL);
return s;
}
// per-network board(s): champion podium + the rest as a ledger
for b in boards { for b in boards {
if b.rows.is_empty() { if b.rows.is_empty() {
continue; continue;
} }
s.push_str("<section class=\"board fade\">");
if multi {
s.push_str(&format!( s.push_str(&format!(
"<section class=\"netsec\"><div class=\"net\"><h2>{}</h2>\ "<div class=\"eyebrow\">network<span>{}</span></div>",
<span class=\"count\">{} {}</span></div><ol class=\"board\">", esc(&b.network)
esc(&b.network),
b.rows.len(),
if b.rows.len() == 1 {
"entry"
} else {
"entries"
}
)); ));
for (i, r) in b.rows.iter().enumerate() {
s.push_str(&row(i + 1, r));
} }
s.push_str("</ol></section>"); s.push_str(&podium(b));
s.push_str(&ledger(b));
s.push_str("</section>");
} }
}
s.push_str(&SCRIPT.replace("__EPOCH__", &updated_epoch.to_string())); // activity + givers band
s.push_str("<div class=\"band\">");
s.push_str(&activity(&feed, multi));
s.push_str(&givers_panel(&feed));
s.push_str("</div>");
s.push_str(&footer(updated_epoch));
s.push_str(SCRIPT);
s.push_str(TAIL);
s s
} }
fn row(rank: usize, r: &Row) -> String { fn stat(num: &str, label: &str) -> String {
let medal = match rank {
1 => "🥇".to_string(),
2 => "🥈".to_string(),
3 => "🥉".to_string(),
n => n.to_string(),
};
let top = if rank <= 3 { " top" } else { "" };
let (klass, sign) = match r.count {
n if n > 0 => ("pos", "+"),
n if n < 0 => ("neg", ""),
_ => ("zero", ""),
};
let mut who = format!("<div class=\"name\">{}</div>", esc(&r.thing));
if let Some(reason) = &r.reason {
match &r.by {
Some(by) => who.push_str(&format!(
"<div class=\"reason\">\u{201C}{}\u{201D} <span class=\"by\">\u{2014} {}</span></div>",
esc(reason),
esc(by)
)),
None => who.push_str(&format!(
"<div class=\"reason\">\u{201C}{}\u{201D}</div>",
esc(reason)
)),
}
}
format!( format!(
"<li class=\"row\"><span class=\"rank{top}\">{medal}</span>\ "<div class=\"stat\"><span class=\"num\">{}</span><span class=\"lab\">{}</span></div>",
<div class=\"who\">{who}</div>\ esc(num),
<span class=\"score {klass}\">{sign}{}</span></li>", esc(label)
r.count )
}
fn podium(b: &Board) -> 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() {
let rank = i + 1;
let crown = match rank {
1 => "👑",
2 => "🥈",
3 => "🥉",
_ => "",
};
let spark = sparkline(&series_for(&r.thing, r.count, &b.events));
let reason = match (&r.reason, &r.by) {
(Some(why), Some(by)) => format!(
"<div class=\"why\">\u{201C}{}\u{201D} <span class=\"by\">\u{2014} {}</span></div>",
esc(why),
esc(by)
),
(Some(why), None) => format!("<div class=\"why\">\u{201C}{}\u{201D}</div>", esc(why)),
_ => String::new(),
};
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>",
avatar(&r.thing, "lg"),
esc(&r.thing),
klass(r.count),
signed(r.count),
));
}
out.push_str("</ol>");
out
}
fn ledger(b: &Board) -> String {
if b.rows.len() <= 3 {
return String::new();
}
let mut out = String::from("<div class=\"eyebrow\">the ledger</div><ol class=\"list\">");
for (i, r) in b.rows.iter().enumerate().skip(3) {
let rank = i + 1;
let spark = sparkline(&series_for(&r.thing, r.count, &b.events));
let reason = match (&r.reason, &r.by) {
(Some(why), Some(by)) => format!(
"<span class=\"why\">\u{201C}{}\u{201D} \u{2014} {}</span>",
esc(why),
esc(by)
),
(Some(why), None) => format!("<span class=\"why\">\u{201C}{}\u{201D}</span>", esc(why)),
_ => String::new(),
};
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),
));
}
out.push_str("</ol>");
out
}
fn activity(feed: &[(&str, &Event)], multi: bool) -> String {
let mut out = String::from(
"<section class=\"panel fade\"><div class=\"eyebrow live-h\">\
<span class=\"live\"></span>recent activity</div>",
);
if feed.is_empty() {
out.push_str(
"<div class=\"empty-panel\">Nothing yet. The next <code>nick++</code> shows up here.</div></section>",
);
return out;
}
out.push_str("<ul class=\"timeline\">");
for (net, e) in feed.iter().take(40) {
let actor = e.by.clone().unwrap_or_else(|| "someone".to_string());
let dchip = 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(),
};
let netlabel = if multi {
format!("<span class=\"net\">{}</span>", esc(net))
} else {
String::new()
};
out.push_str(&format!(
"<li class=\"ev {}\">{}<div class=\"evbody\"><div class=\"evline\">\
<b>{}</b> <span class=\"verb\">gave</span> <b>{}</b> \
<span class=\"chip {}\">{dchip}</span>{netlabel}</div>{why}\
<time class=\"ago\" data-t=\"{}\"></time></div></li>",
klass(e.delta),
avatar(&actor, "sm"),
esc(&actor),
esc(&e.thing),
klass(e.delta),
e.t,
));
}
out.push_str("</ul></section>");
out
}
fn givers_panel(feed: &[(&str, &Event)]) -> String {
let givers = top_givers(feed, 8);
let mut out =
String::from("<section class=\"panel fade\"><div class=\"eyebrow\">top givers</div>");
if givers.is_empty() {
out.push_str(
"<div class=\"empty-panel\">No one has handed out karma yet. Be the first.</div></section>",
);
return out;
}
out.push_str("<ol class=\"list givers\">");
for (i, g) in givers.iter().enumerate() {
out.push_str(&format!(
"<li class=\"row\"><span class=\"rank\">{}</span>{}\
<div class=\"who\"><div class=\"nick\">{}</div>\
<span class=\"why\">{} gift{}</span></div>\
<span class=\"score {}\">{}</span></li>",
i + 1,
avatar(&g.name, "sm"),
esc(&g.name),
g.gives,
if g.gives == 1 { "" } else { "s" },
klass(g.points),
signed(g.points),
));
}
out.push_str("</ol></section>");
out
}
fn footer(epoch: u64) -> String {
format!(
"<footer class=\"fade\">give karma by saying <code>nick++</code> in the channel \
&middot; built by <a href=\"https://git.devtronic.pro/reverse/rubot\">rubot</a> \
&middot; updated <time class=\"ago\" data-t=\"{epoch}\"></time></footer>"
) )
} }
@ -141,70 +478,154 @@ const HEAD: &str = r##"<!doctype html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>karma devtronic</title> <title>karma · devtronic</title>
<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"> <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> <style>
:root{--bg:#0f1216;--card:#171b21;--card2:#1b2028;--line:#252b34;--fg:#e6e9ee;--muted:#8b95a3;--pos:#4ade80;--neg:#f87171;--zero:#8b95a3;--accent:#7c9cff} :root{
--bg:#12131a;--bg2:#0c0d12;--panel:#191b25;--panel2:#20232f;--line:#2b2e3d;
--fg:#ece9f5;--muted:#8d8fab;--faint:#5c5e75;
--gold:#f5b642;--up:#5fd39f;--down:#f2757a;
--mono:ui-monospace,"SF Mono","JetBrains Mono","Cascadia Code",Menlo,Consolas,monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
*{box-sizing:border-box} *{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif} html{-webkit-text-size-adjust:100%}
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline} body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 var(--sans);
.wrap{max-width:720px;margin:0 auto;padding:32px 18px 64px} background-image:radial-gradient(900px 380px at 50% -140px,rgba(245,182,66,.10),transparent 70%);
header{text-align:center;margin-bottom:24px} background-repeat:no-repeat}
h1{margin:0;font-size:34px;letter-spacing:-.02em} .page{max-width:860px;margin:0 auto;padding:40px 20px 72px}
.sub{margin:6px 0 0;color:var(--muted);font-size:14px} a{color:var(--gold);text-decoration:none}a:hover{text-decoration:underline}
.search{width:100%;margin:0 0 22px;padding:11px 14px;border-radius:10px;border:1px solid var(--line);background:var(--card);color:var(--fg);font-size:15px} code{font-family:var(--mono);font-size:.88em;background:var(--panel2);border:1px solid var(--line);
.search:focus{outline:none;border-color:var(--accent)} border-radius:6px;padding:1px 6px;color:var(--gold)}
.netsec{margin-top:26px} :focus-visible{outline:2px solid var(--gold);outline-offset:2px;border-radius:6px}
.net{display:flex;align-items:baseline;gap:10px;margin:0 2px 10px}
.net h2{margin:0;font-size:15px;font-weight:600;text-transform:lowercase} /* hero */
.net .count{margin-left:auto;color:var(--muted);font-size:12px} .hero{text-align:center;padding:14px 0 30px}
ol.board{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px} .prompt{font-family:var(--mono);font-size:12px;letter-spacing:.06em;color:var(--faint);margin-bottom:10px}
li.row{display:flex;align-items:center;gap:12px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px 14px} .prompt::before{content:"$ ";color:var(--up)}
li.row:hover{background:var(--card2)} .mark{font-family:var(--mono);font-weight:700;font-size:clamp(46px,12vw,84px);line-height:.9;
.rank{flex:none;width:30px;text-align:center;color:var(--muted);font-variant-numeric:tabular-nums} margin:0;letter-spacing:-.04em;
.rank.top{font-size:18px} background:linear-gradient(180deg,#fff,#c9c7db);-webkit-background-clip:text;background-clip:text;color:transparent}
.cursor{color:var(--gold);-webkit-text-fill-color:var(--gold)}
.tag{color:var(--muted);max-width:44ch;margin:16px auto 0;text-wrap:balance}
.stats{display:flex;flex-wrap:wrap;justify-content:center;gap:10px;margin-top:26px}
.stat{background:var(--panel);border:1px solid var(--line);border-radius:14px;
padding:12px 18px;min-width:104px;display:flex;flex-direction:column;gap:2px}
.stat .num{font-family:var(--mono);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums}
.stat .lab{font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--muted)}
.eyebrow{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted);
margin:34px 2px 14px;display:flex;align-items:center;gap:10px}
.eyebrow span{color:var(--gold);font-family:var(--mono);letter-spacing:.04em}
/* podium */
.podium{list-style:none;margin:0;padding:0;display:flex;justify-content:center;align-items:flex-end;
gap:14px;flex-wrap:wrap}
.pillar{background:linear-gradient(180deg,var(--panel2),var(--panel));border:1px solid var(--line);
border-radius:18px;padding:20px 18px 18px;width:210px;display:flex;flex-direction:column;
align-items:center;gap:6px;position:relative}
.pillar.r1{order:2;width:236px;padding-top:26px;border-color:rgba(245,182,66,.45);
box-shadow:0 0 0 1px rgba(245,182,66,.18),0 20px 50px -24px rgba(245,182,66,.4)}
.pillar.r2{order:1}.pillar.r3{order:3}
.crown{font-size:22px;line-height:1;height:22px}
.pillar .nick{font-family:var(--mono);font-weight:600;font-size:17px;word-break:break-word;text-align:center}
.pillar .score{font-family:var(--mono);font-weight:700;font-size:30px;font-variant-numeric:tabular-nums}
.pillar .why{font-size:12px;color:var(--muted);text-align:center;text-wrap:balance;margin-top:2px}
.pillar .by{color:var(--faint)}
/* avatars */
.ava{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;
font-family:var(--mono);font-weight:600;color:#fff;flex:none;
background:hsl(var(--h) 42% 42%);box-shadow:inset 0 0 0 1px rgba(255,255,255,.12)}
.ava.lg{width:56px;height:56px;font-size:19px}
.ava.sm{width:34px;height:34px;font-size:13px}
/* score colors */
.score.up,.chip.up{color:var(--up)}.score.down,.chip.down{color:var(--down)}.score.zero{color:var(--muted)}
/* sparkline */
.spark{width:104px;height:30px;flex:none}
.spark polyline{fill:none;stroke-width:1.6;vector-effect:non-scaling-stroke;stroke-linejoin:round;stroke-linecap:round}
.spark.up polyline{stroke:var(--up)}.spark.down polyline{stroke:var(--down)}
.spark.up circle{fill:var(--up)}.spark.down circle{fill:var(--down)}
.pillar .spark{width:150px;margin-top:4px}
/* ledger + list rows */
.list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}
.row{display:flex;align-items:center;gap:13px;background:var(--panel);border:1px solid var(--line);
border-radius:13px;padding:11px 15px}
.row:hover{background:var(--panel2)}
.rank{font-family:var(--mono);color:var(--faint);font-size:13px;width:22px;text-align:right;flex:none;
font-variant-numeric:tabular-nums}
.who{flex:1;min-width:0} .who{flex:1;min-width:0}
.name{font-weight:600;word-break:break-word} .nick{font-family:var(--mono);font-weight:600;word-break:break-word}
.reason{color:var(--muted);font-size:13px;margin-top:2px;word-break:break-word} .why{display:block;color:var(--muted);font-size:12.5px;margin-top:1px;word-break:break-word}
.reason .by{color:var(--fg);opacity:.8} .row .score{font-family:var(--mono);font-weight:700;font-size:18px;font-variant-numeric:tabular-nums;
.score{flex:none;font-variant-numeric:tabular-nums;font-weight:700;font-size:17px;min-width:46px;text-align:right} min-width:46px;text-align:right;flex:none}
.score.pos{color:var(--pos)}.score.neg{color:var(--neg)}.score.zero{color:var(--zero)}
.empty{text-align:center;color:var(--muted);padding:48px 6px} /* activity + givers band */
.empty code,footer code{background:var(--card);border:1px solid var(--line);border-radius:6px;padding:2px 6px;color:var(--fg)} .band{display:grid;grid-template-columns:1fr;gap:16px;margin-top:8px}
footer{text-align:center;color:var(--muted);font-size:12px;margin-top:44px} @media(min-width:720px){.band{grid-template-columns:1.35fr 1fr;align-items:start}}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:6px 18px 18px}
.panel .eyebrow{margin-top:18px}
.empty-panel{color:var(--muted);font-size:14px;padding:10px 2px 14px}
.live-h .live{width:8px;height:8px;border-radius:50%;background:var(--up);
box-shadow:0 0 0 4px rgba(95,211,159,.16)}
.timeline{list-style:none;margin:0;padding:0;display:flex;flex-direction:column}
.ev{display:flex;gap:12px;padding:11px 0;border-bottom:1px solid var(--line)}
.ev:last-child{border-bottom:0}
.evbody{min-width:0;flex:1}
.evline{word-break:break-word}.evline b{font-family:var(--mono);font-weight:600}
.verb{color:var(--muted)}
.chip{font-family:var(--mono);font-weight:700;font-size:12px;border:1px solid var(--line);
border-radius:6px;padding:0 6px;margin-left:2px}
.chip.up{border-color:rgba(95,211,159,.4)}.chip.down{border-color:rgba(242,117,122,.4)}
.net{font-family:var(--mono);font-size:11px;color:var(--faint);margin-left:6px}
.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}
/* empty (no karma at all) */
.empty{text-align:center;color:var(--muted);padding:56px 10px}
@media(max-width:560px){
.pillar,.pillar.r1{width:100%;order:0}
.pillar.r1{order:-1}
.row{gap:10px;padding:10px 12px}
.row .spark{display:none}
}
@media(prefers-reduced-motion:no-preference){
.fade{animation:rise .55s cubic-bezier(.2,.7,.2,1) both}
.board{animation-delay:.05s}.band{animation-delay:.1s}
@keyframes rise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
.cursor{animation:blink 1.15s step-end infinite}
@keyframes blink{50%{opacity:0}}
.live{animation:pulse 1.9s ease-in-out infinite}
@keyframes pulse{50%{opacity:.4;box-shadow:0 0 0 6px rgba(95,211,159,0)}}
}
</style> </style>
</head> </head>
<body> <body>
<main class="wrap"> <div class="page">
<header>
<h1> karma</h1>
<p class="sub">community karma, tracked by <a href="https://git.devtronic.pro/reverse/rubot">rubot</a></p>
</header>
"##; "##;
const SEARCH: &str = r##"<input id="q" class="search" type="search" placeholder="filter…" autocomplete="off" aria-label="filter">"##; const EMPTY: &str = r##"<div class="empty fade">No karma yet.<br>Say <code>nick++</code> in the channel and this board comes alive.</div>"##;
const EMPTY: &str = r##"<div class="empty">no karma yet — say <code>nick++</code> in the channel to give some.</div>"##; const SCRIPT: &str = r##"<script>
const SCRIPT: &str = r##"<footer>say <code>nick++</code> in the channel to give karma &middot; <a href="https://git.devtronic.pro/reverse/rubot">rubot</a> &middot; updated <span id="up"></span></footer>
</main>
<script>
(function(){ (function(){
var q=document.getElementById('q'); function ago(sec){
if(q){q.addEventListener('input',function(){ var d=Math.max(0,Math.floor(Date.now()/1000)-sec);
var t=q.value.toLowerCase(); if(d<45)return"just now";
document.querySelectorAll('section.netsec').forEach(function(sec){ var u=[[31536000,"y"],[2592000,"mo"],[604800,"w"],[86400,"d"],[3600,"h"],[60,"m"]];
var any=false; for(var i=0;i<u.length;i++){if(d>=u[i][0])return Math.floor(d/u[i][0])+u[i][1]+" ago";}
sec.querySelectorAll('li.row').forEach(function(li){ return Math.floor(d/1)+"s ago";
var show=li.textContent.toLowerCase().indexOf(t)>=0; }
li.style.display=show?'':'none';if(show)any=true;}); document.querySelectorAll("time.ago[data-t]").forEach(function(t){
sec.style.display=any?'':'none';}); var v=parseInt(t.getAttribute("data-t"),10);if(v)t.textContent=ago(v);
});} });
var up=document.getElementById('up'); setTimeout(function(){location.reload();},60000);
if(up){try{up.textContent=new Date(__EPOCH__*1000).toLocaleString();}catch(e){}}
setTimeout(function(){if(!q||!q.value)location.reload();},60000);
})(); })();
</script> </script>
</body>
</html>
"##; "##;
const TAIL: &str = "</div></body></html>\n";

View file

@ -1,5 +1,5 @@
use rustbot::bot::module::{Action, Chat, Command, Module}; use rustbot::bot::module::{Action, Chat, Command, Module};
use rustbot::bot::modules::karma::{extract_reason, parse_changes, Karma}; use rustbot::bot::modules::karma::{extract_reason, format_event, parse_changes, Karma};
fn chat<'a>(sender: &'a str, text: &'a str) -> Chat<'a> { fn chat<'a>(sender: &'a str, text: &'a str) -> Chat<'a> {
Chat { Chat {
@ -166,3 +166,45 @@ fn milestone_announced_at_ten() {
assert!(msg.contains("rust reached 10 karma"), "{msg}"); assert!(msg.contains("rust reached 10 karma"), "{msg}");
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);
} }
#[test]
fn format_event_is_one_json_line() {
let line = format_event(1700000000, "rust", 1, "alice", Some("clean PR"));
assert!(line.ends_with('\n'));
assert_eq!(line.matches('\n').count(), 1);
assert!(line.contains("\"t\":1700000000"), "{line}");
assert!(line.contains("\"o\":\"rust\""), "{line}");
assert!(line.contains("\"d\":1"), "{line}");
assert!(line.contains("\"by\":\"alice\""), "{line}");
assert!(line.contains("\"why\":\"clean PR\""), "{line}");
// no reason -> no why field
let bare = format_event(1, "docs", -1, "bob", None);
assert!(!bare.contains("why"), "{bare}");
// quotes in a reason are escaped
let q = format_event(1, "x", 1, "y", Some("he said \"hi\""));
assert!(q.contains("\\\"hi\\\""), "{q}");
}
#[test]
fn on_message_appends_to_the_event_log() {
let path = std::env::temp_dir().join("rubot-test-karma-ev.json");
let log = std::env::temp_dir().join("rubot-test-karma-ev.events.jsonl");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&log);
let mut k = Karma::with_path(path.clone());
k.on_message(&chat("alice", "rust++ for the clean PR"));
k.on_message(&chat("bob", "rust-- python++"));
let body = std::fs::read_to_string(&log).expect("event log written");
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 3, "one line per bump: {body}");
assert!(lines[0].contains("\"o\":\"rust\"") && lines[0].contains("\"by\":\"alice\""));
assert!(
lines[0].contains("\"why\":\"the clean PR\""),
"{}",
lines[0]
);
// the two-bump message attributes no reason
assert!(!lines[1].contains("why") && !lines[2].contains("why"));
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&log);
}

View file

@ -1,4 +1,10 @@
use rustbot::karma_web::{parse_board, render}; use rustbot::karma_web::{parse_board, parse_events, render};
fn board_with_events(net: &str, store: &str, log: &str) -> rustbot::karma_web::Board {
let mut b = parse_board(net, store);
b.events = parse_events(log);
b
}
#[test] #[test]
fn parses_flat_number_format() { fn parses_flat_number_format() {
@ -6,7 +12,6 @@ fn parses_flat_number_format() {
assert_eq!(b.rows.len(), 1); assert_eq!(b.rows.len(), 1);
assert_eq!(b.rows[0].thing, "romaka"); assert_eq!(b.rows[0].thing, "romaka");
assert_eq!(b.rows[0].count, 2); assert_eq!(b.rows[0].count, 2);
assert!(b.rows[0].reason.is_none());
} }
#[test] #[test]
@ -25,36 +30,81 @@ fn sorts_descending_by_count() {
} }
#[test] #[test]
fn renders_scores_and_reason() { fn parses_event_log_lines() {
let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"alice\",\"why\":\"clean PR\"}\n\
{\"t\":200,\"o\":\"rust\",\"d\":-1,\"by\":\"bob\"}\n\
\n\
garbage line\n";
let ev = parse_events(log);
assert_eq!(ev.len(), 2);
assert_eq!(ev[0].thing, "rust");
assert_eq!(ev[0].delta, 1);
assert_eq!(ev[0].by.as_deref(), Some("alice"));
assert_eq!(ev[0].why.as_deref(), Some("clean PR"));
assert_eq!(ev[1].delta, -1);
assert!(ev[1].why.is_none());
}
#[test]
fn renders_hero_podium_and_scores() {
let b = parse_board( let b = parse_board(
"libera", "libera",
r#"{"rust":{"n":5,"why":"clean PR","by":"alice"}}"#, r#"{"rust":{"n":5,"why":"clean PR","by":"alice"}}"#,
); );
let html = render(&[b], 0); let html = render(&[b], 0);
assert!(html.contains("libera"), "{html}"); assert!(
assert!(html.contains(">rust<"), "{html}"); html.contains(">karma<"),
assert!(html.contains(">+5<"), "{html}"); "wordmark missing: has no karma mark"
assert!(html.contains("clean PR"), "{html}"); );
assert!(html.contains("alice"), "{html}"); assert!(html.contains("podium"), "no podium");
assert!(html.contains(">rust<"), "champion nick missing");
assert!(html.contains(">+5<"), "score missing");
assert!(html.contains("clean PR"), "reason missing");
} }
#[test] #[test]
fn escapes_untrusted_irc_input() { fn renders_activity_and_givers_from_events() {
let b = parse_board( let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"alice\",\"why\":\"clean PR\"}\n\
{\"t\":200,\"o\":\"docs\",\"d\":1,\"by\":\"alice\"}\n";
let b = board_with_events("libera", r#"{"rust":1,"docs":1}"#, log);
let html = render(&[b], 0);
assert!(html.contains("recent activity"), "no activity section");
assert!(html.contains("top givers"), "no givers section");
assert!(html.contains("gave"), "no feed verb");
// alice made two gifts -> shown in givers panel
assert!(
html.contains("2 gifts"),
"giver count wrong: {}",
html.contains("gift")
);
assert!(
html.contains("data-t=\"200\""),
"feed sorted/newest-first missing"
);
}
#[test]
fn empty_states_are_friendly() {
// no karma at all
let html = render(&[], 0);
assert!(html.contains("No karma yet"), "{html}");
// karma but no event history yet
let b = parse_board("libera", r#"{"romaka":2}"#);
let html = render(&[b], 0);
assert!(html.contains("Nothing yet"), "empty activity state missing");
assert!(html.contains("Be the first"), "empty givers state missing");
}
#[test]
fn escapes_untrusted_irc_input_everywhere() {
let log = "{\"t\":1,\"o\":\"<script>\",\"d\":1,\"by\":\"a&b\",\"why\":\"\\\"><img src=x>\"}\n";
let b = board_with_events(
"t", "t",
r#"{"<script>":{"n":1,"why":"\"><img src=x>","by":"a&b"}}"#, r#"{"<script>":{"n":1,"why":"\"><img src=x>","by":"a&b"}}"#,
log,
); );
let html = render(&[b], 0); let html = render(&[b], 0);
// the page legitimately has its own <script> block; what matters is that the assert!(!html.contains("<img"), "raw img tag leaked");
// untrusted nick/reason are escaped and never emit a live tag of their own. assert!(html.contains("&lt;script&gt;"), "nick not escaped");
assert!(!html.contains("<img"), "raw img tag leaked: {html}"); assert!(html.contains("a&amp;b"), "giver not escaped");
assert!(html.contains("&lt;script&gt;"), "{html}");
assert!(html.contains("a&amp;b"), "{html}");
}
#[test]
fn empty_shows_instructions() {
let html = render(&[], 0);
assert!(html.contains("no karma yet"), "{html}");
assert!(html.contains("nick++"), "{html}");
} }