Something went wrong. Try again.
unified development activity
Something went wrong. Try again.
77 kB · 2141 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142//! Turn the database into the small set of JSON documents the dashboard//! reads. Every number here is derived from distinct Git object names; no//! number is a sum of an indexer's per-ref counts.
use std::collections::{BTreeMap, HashMap, HashSet};
use anyhow::Result;use chrono::{Duration, Utc};use serde::Serialize;
use crate::config::Config;use crate::crawl::CrawlReport;use crate::db::{DailyRow, Db, now_string};use crate::global_summary::GlobalSummary;use crate::model::IdentityKind;
#[derive(Debug, Serialize)]pub struct SiteData { pub summary: Summary, pub identities: Vec<IdentityView>, /// The projects the date chart and the time wheel stack, bottom up. The /// per-project counts in `daily` and `hourly` follow this order, then /// one more for every other project. pub chart_projects: Vec<ChartProject>, pub daily: Vec<DailyPoint>, /// The same commits by UTC hour, for the time-of-day wheel. pub hourly: HourlySeries, /// Conventional Commits types per week over the same window. pub commit_types: crate::commit_types::CommitTypes, /// Author-to-commit gaps of Tangled changes over the same window. pub land_gap: crate::land_gap::LandGap, /// The agents' token burn over the same window; absent with none. #[serde(skip_serializing_if = "Option::is_none")] pub burn: Option<crate::burn::BurnSeries>, pub repos: Vec<RepoView>, /// The top projects of every period, ranked by commits in the last 30 /// days; the sidebar ranks them again for the period it shows. pub projects: Vec<ProjectView>, /// Daily commits per project over the same window as `daily`. pub project_timelines: crate::project_timelines::ProjectTimelines, pub recent: Vec<RecentView>, pub status: StatusView, /// Each day's commits by UTC month, written beside `site.json` as /// `data/days/YYYY-MM.json` rather than inside it. #[serde(skip)] pub day_shards: Vec<crate::day_shards::MonthShard>,}
#[derive(Debug, Serialize)]pub struct Summary { pub generated_at: String, pub title: String, pub site_url: String, /// IANA zone the time-of-day wheel is drawn in; unset means the viewer's. pub timezone: Option<String>, /// Where the browser reads the live "agentic activity" card from; /// absent, no card. pub activity: Option<ActivityFeed>, pub me_tangled: u64, pub me_github: u64, pub me_total: u64, pub bots_tangled: u64, pub everything_mine: u64, pub tangled_total: u64, pub tangled_total_365: u64, /// True only when a fresh global summary supplied the denominator. pub share_available: bool, /// Timestamp of the global summary, even when it is stale. pub global_summary_at: Option<String>, pub global_summary_stale: bool, pub global_partial: bool, pub share_me: f64, pub share_all: f64, /// The same ratio over the last twelve months, which is the window the /// dashboard is asked to cover at minimum. pub share_me_365: f64, pub share_all_365: f64, /// The share metric is deliberately period-scoped and covers all of the /// person's Tangled identities (and, separately, their bots) in that /// period. It is not a per-repository or per-identity figure. pub share_periods: SharePeriods, /// The share over the trailing 30 days, one point per day, as /// `[date, percent]`. Empty unless the global summary has owner counts. pub share_trend: Vec<(String, f64)>, /// How much of the public Tangled repository index has been walked. The /// share is only meaningful once this is high; the site says so. pub repos_total: u64, pub repos_scanned: u64, pub coverage_pct: f64, pub backfill_complete: bool, pub prior_claim: PriorClaim, pub windows: Windows, pub github_configured: bool, pub methodology: String,}
/// The live card's source. The browser fetches/// `{url}/scrobbles?operator={operator}`; nothing is fetched at build time.#[derive(Debug, PartialEq, Serialize)]pub struct ActivityFeed { pub url: String, pub operator: String, /// False hides each line's text, model and repo, leaving emoji, agent /// and age. pub text: bool,}
/// The feed for the first personal identity, when the card is on.fn activity_feed(config: &crate::config::Config) -> Option<ActivityFeed> { Some(ActivityFeed { url: config.activity_url.clone()?, operator: config.personal.first()?.did.clone(), text: config.activity_text, })}
#[derive(Debug, Serialize)]pub struct SharePeriods { #[serde(rename = "30d")] pub days_30: SharePeriod, #[serde(rename = "90d")] pub days_90: SharePeriod, #[serde(rename = "1y")] pub year: SharePeriod, pub all: SharePeriod,}
#[derive(Debug, Serialize)]pub struct SharePeriod { /// Logical changes: one per Change-Id when present, otherwise per SHA. pub me_tangled: u64, pub bots_tangled: u64, pub tangled_total: u64, /// Raw Git objects before rebase/resubmit collapse, for context. pub objects_me_tangled: u64, pub objects_bots_tangled: u64, pub objects_tangled_total: u64, pub share_me: f64, pub share_all: f64,}
#[derive(Debug, Serialize)]pub struct PriorClaim { pub percent: f64, pub date: &'static str, pub url: &'static str, pub note: &'static str,}
#[derive(Debug, Serialize)]pub struct Windows { pub me_7: u64, pub me_30: u64, pub me_90: u64, pub me_365: u64, pub bots_7: u64, pub bots_30: u64, pub bots_365: u64, pub all_7: u64, pub all_30: u64, pub all_365: u64,}
#[derive(Debug, Serialize)]pub struct IdentityView { pub did: String, pub handle: String, pub kind: String, pub color: String, pub active: bool, pub operator: Option<String>, pub tangled: u64, pub github: u64, pub last_30: u64,}
/// A project the date chart and the time wheel colour, labelled like the/// sidebar.#[derive(Debug, PartialEq, Serialize)]pub struct ChartProject { #[serde(flatten)] pub label: ProjectLabel, /// A `--series-*` token, kept by the project from build to build. pub color: String, /// Logical changes over the year window. pub total: u64,}
/// The owner's and their agents' logical changes on one UTC day, broken/// down two ways: by identity and by project. Empty breakdowns are left out/// of the payload.#[derive(Debug, PartialEq, Serialize)]pub struct DailyPoint { pub date: String, pub total: u64, /// Personal and GitHub DIDs. One map rather than one field per forge, so a /// second account (the redacted work account) gets its own shade without a /// schema change. #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub identities: BTreeMap<String, u64>, /// Every agent together. #[serde(skip_serializing_if = "is_zero")] pub bots: u64, /// One count per entry of `chart_projects`, then the rest. #[serde(skip_serializing_if = "Vec::is_empty")] pub projects: Vec<u64>,}
fn is_zero(value: &u64) -> bool { *value == 0}
/// Commits per UTC hour over the daily window, sparse. Each row is the/// hour's start in hours since the Unix epoch, then its counts. The browser/// turns hours into a time of day in the display zone, so daylight saving/// lands on the right dates.#[derive(Debug, Default, Serialize)]pub struct HourlySeries { /// The columns of `rows`: an identity DID, or `bots` for every agent. pub series: Vec<String>, pub rows: Vec<Vec<i64>>, /// The same hours by project: one count per entry of `chart_projects`, /// then the rest. pub projects: Vec<Vec<i64>>,}
#[derive(Debug, Serialize)]pub struct RepoView { pub repo_key: String, pub name: String, /// `owner/repo` for Tangled and GitHub; a redaction label otherwise. pub full_name: String, pub url: String, pub website: Option<String>, /// The project's live site, linked beside its name; never set for a /// redacted repository. pub site: Option<String>, pub owner: String, pub knot: String, pub commits: u64, pub mine: u64, pub last_30: u64,}
/// How a project is named and linked, shared by the sidebar and the/// timelines card.#[derive(Debug, PartialEq, Serialize)]pub struct ProjectLabel { pub repo_key: String, pub name: String, pub full_name: String, pub url: String, pub website: Option<String>, /// The project's live site, linked beside its name; never set for a /// redacted repository. pub site: Option<String>, pub knot: String, pub redacted: bool,}
#[derive(Debug, Serialize)]pub struct ProjectView { #[serde(flatten)] pub label: ProjectLabel, /// Logical changes in each period the page offers. pub commits: PeriodCounts, /// Logical changes in the 90 days before the last 30. pub prior_90: u64, /// Logical changes in each of the last 12 seven-day windows, oldest /// first, counted the same way as `commits`. pub weekly: Vec<u64>,}
/// A count for each of the page's periods, keyed like `SharePeriods`.#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize)]pub struct PeriodCounts { #[serde(rename = "30d")] pub days_30: u64, #[serde(rename = "90d")] pub days_90: u64, #[serde(rename = "1y")] pub year: u64, pub all: u64,}
#[derive(Debug, Serialize)]pub struct RecentView { pub sha: String, pub short: String, pub author_did: String, pub author: String, pub color: String, pub repo_key: String, pub repo: String, pub url: String, pub authored_at: String, pub message: String,}
#[derive(Debug, Serialize)]pub struct StatusView { pub repos_total: u64, pub repos_scanned: u64, pub repos_failed: u64, pub branches_total: u64, pub branches_indexed: u64, pub bots_known: usize, pub bots_active: usize, pub last_crawl_started: Option<String>, pub last_crawl_finished: Option<String>, pub global_summary_at: Option<String>, pub global_summary_stale: bool, pub budget_exhausted: bool, pub scope: String, pub queue_this_run: usize, pub commits_seen_this_run: usize, pub commits_new_this_run: usize, pub errors: Vec<ErrorView>, pub github: GithubStatus, pub work_inbox: Option<WorkInboxStatus>,}
#[derive(Debug, Serialize)]pub struct WorkInboxStatus { pub source: String, pub generated_at: Option<String>, pub commits_read: usize, pub commits_new: usize, pub repos: usize,}
#[derive(Debug, Serialize)]pub struct ErrorView { pub repo: String, pub error: String,}
#[derive(Debug, Serialize)]pub struct GithubStatus { pub configured: bool, pub repos: usize, pub repos_scanned: usize, pub commits_new: usize, pub errors: usize, pub accounts: Vec<GithubAccountStatus>,}
#[derive(Debug, Serialize)]pub struct GithubAccountStatus { pub label: String, pub login: String, pub redacted: bool, pub repos: usize, pub repos_scanned: usize, pub commits_new: usize, pub errors: usize,}
/// The first UTC day of the daily window, which holds a year.pub fn window_start(now: chrono::DateTime<Utc>) -> chrono::NaiveDate { (now - Duration::days(364)).date_naive()}
pub fn build( config: &Config, db: &Db, crawl: &CrawlReport, global: Option<&GlobalSummary>,) -> Result<SiteData> { let identities = db.list_identities()?; let tangled_counts = db.identity_counts_logical("tangled")?; let github_counts = db.identity_counts_logical("github")?;
let personal: Vec<_> = identities .iter() .filter(|identity| identity.kind == IdentityKind::Personal) .cloned() .collect(); let personal_set: HashSet<String> = personal .iter() .map(|identity| identity.did.clone()) .collect(); let github_identities: Vec<_> = identities .iter() .filter(|identity| identity.kind == IdentityKind::Github) .cloned() .collect(); let github_set: HashSet<String> = github_identities .iter() .map(|identity| identity.did.clone()) .collect(); let bots: Vec<_> = identities .iter() .filter(|identity| identity.kind == IdentityKind::Bot) .cloned() .collect(); let bot_set: HashSet<String> = bots.iter().map(|identity| identity.did.clone()).collect(); let bot_active: HashSet<String> = bots .iter() .filter(|identity| identity.active) .map(|identity| identity.did.clone()) .collect();
let personal_dids: Vec<String> = personal_set.iter().cloned().collect(); let github_dids: Vec<String> = github_set.iter().cloned().collect(); let bot_dids: Vec<String> = bot_set.iter().cloned().collect(); let mut me_dids: Vec<String> = personal_dids.clone(); me_dids.extend(github_dids.iter().cloned()); let mut all_mine: Vec<String> = me_dids.clone(); all_mine.extend(bot_dids.iter().cloned()); let me_set: HashSet<String> = me_dids.iter().cloned().collect();
let me_tangled = db.count_logical_commits(&personal_dids, Some("tangled"), None)?; let me_github = db.count_logical_commits(&github_dids, Some("github"), None)?; let bots_tangled = db.count_logical_commits(&bot_dids, Some("tangled"), None)?; let everything_mine = db.count_logical_commits(&all_mine, None, None)?; let me_total = db.count_logical_commits(&me_dids, None, None)?;
let now = Utc::now(); let since_7 = period_start(now, 7); let since_30 = period_start(now, 30); let since_90 = period_start(now, 90); let since_365 = period_start(now, 365);
let global_fresh = global.is_some_and(|value| value.is_fresh(config.global_summary_max_age_days)); let global_at = global.map(|value| value.generated_at.clone()); let global_stale = global.is_some() && !global_fresh; let global_partial = global.is_some_and(|value| value.partial); let global_coverage_pct = global .map(|value| percent(value.repos_reached, value.repos_total)) .unwrap_or(0.0); let global_available = global_fresh && global_coverage_pct >= config.global_min_coverage_pct; // A summary that carries the owner's and agents' per-day counts supplies // both sides of the share from one crawl, and every share window ends on // its date, so a numerator that kept growing after the crawl can't inflate // the ratio. An older summary falls back to the live owned-only counts over // windows ending now. let snapshot = global.filter(|value| global_available && value.has_owner_counts()); let share_end = snapshot.and_then(GlobalSummary::generated).unwrap_or(now); let share_since_30 = period_start(share_end, 30); let share_since_90 = period_start(share_end, 90); let share_since_365 = period_start(share_end, 365); let tangled_total = if global_available { global.map(|value| value.total(None)).unwrap_or(0) } else { 0 }; let tangled_total_365 = if global_available { global .map(|value| value.total(Some(&share_since_365))) .unwrap_or(0) } else { 0 };
let share_periods = SharePeriods { days_30: share_period( db, global, global_fresh, &personal_dids, &bot_dids, Some(&share_since_30), snapshot, )?, days_90: share_period( db, global, global_fresh, &personal_dids, &bot_dids, Some(&share_since_90), snapshot, )?, year: share_period( db, global, global_fresh, &personal_dids, &bot_dids, Some(&share_since_365), snapshot, )?, all: share_period( db, global, global_fresh, &personal_dids, &bot_dids, None, snapshot, )?, };
let me_tangled_365 = db.count_logical_commits(&personal_dids, Some("tangled"), Some(&since_365))?;
let windows = Windows { me_7: db.count_logical_commits(&personal_dids, None, Some(&since_7))? + db.count_logical_commits(&github_dids, Some("github"), Some(&since_7))?, me_30: db.count_logical_commits(&personal_dids, None, Some(&since_30))? + db.count_logical_commits(&github_dids, Some("github"), Some(&since_30))?, me_90: db.count_logical_commits(&personal_dids, None, Some(&since_90))? + db.count_logical_commits(&github_dids, Some("github"), Some(&since_90))?, me_365: db.count_logical_commits(&personal_dids, None, Some(&since_365))? + db.count_logical_commits(&github_dids, Some("github"), Some(&since_365))?, bots_7: db.count_logical_commits(&bot_dids, Some("tangled"), Some(&since_7))?, bots_30: db.count_logical_commits(&bot_dids, Some("tangled"), Some(&since_30))?, bots_365: db.count_logical_commits(&bot_dids, Some("tangled"), Some(&since_365))?, all_7: db.count_logical_commits(&all_mine, None, Some(&since_7))?, all_30: db.count_logical_commits(&all_mine, None, Some(&since_30))?, all_365: db.count_logical_commits(&all_mine, None, Some(&since_365))?, };
// The share's numerators: from the snapshot when it has them, otherwise // the live counts (the stats above stay live either way). let (share_me_all, share_bots_all, share_me_365, share_bots_365) = match snapshot { Some(value) => ( value.owner_total(None), value.agent_total(None), value.owner_total(Some(&share_since_365)), value.agent_total(Some(&share_since_365)), ), None => (me_tangled, bots_tangled, me_tangled_365, windows.bots_365), };
let mut identity_views = Vec::new(); // An identity with no commits stays configured, so anything it commits // later is counted, but it isn't listed. for identity in &personal { let tangled = *tangled_counts.get(&identity.did).unwrap_or(&0); if tangled == 0 { continue; } identity_views.push(IdentityView { did: identity.did.clone(), handle: identity.handle.clone(), kind: identity.kind.as_str().to_string(), color: identity.color.clone(), active: identity.active, operator: identity.operator_did.clone(), tangled, github: 0, last_30: db.count_logical_commits( std::slice::from_ref(&identity.did), None, Some(&since_30), )?, }); } for github in &github_identities { let count = *github_counts.get(&github.did).unwrap_or(&0); if count == 0 { continue; } identity_views.push(IdentityView { did: github.did.clone(), handle: github_display_handle(&github.did, &github.handle), kind: github.kind.as_str().to_string(), color: github.color.clone(), active: github.active, operator: None, tangled: 0, github: count, last_30: db.count_logical_commits( std::slice::from_ref(&github.did), Some("github"), Some(&since_30), )?, }); } let mut bot_views: Vec<IdentityView> = Vec::new(); for identity in &bots { let count = *tangled_counts.get(&identity.did).unwrap_or(&0); let last_30 = db.count_logical_commits( std::slice::from_ref(&identity.did), Some("tangled"), Some(&since_30), )?; bot_views.push(IdentityView { did: identity.did.clone(), handle: identity.handle.clone(), kind: identity.kind.as_str().to_string(), color: identity.color.clone(), active: identity.active, operator: identity.operator_did.clone(), tangled: count, github: 0, last_30, }); } bot_views.sort_by(|a, b| { b.tangled .cmp(&a.tangled) .then_with(|| a.handle.cmp(&b.handle)) }); // Keep the identity list useful: every personal handle, the GitHub // account, and the bots that actually committed. An empty agent row would // otherwise outnumber the people in the legend. bot_views.retain(|view| view.tangled > 0); identity_views.extend(bot_views);
let handles: HashMap<String, String> = identities .iter() .filter(|identity| !identity.handle.is_empty()) .map(|identity| { let handle = if identity.kind == IdentityKind::Github { github_display_handle(&identity.did, &identity.handle) } else { identity.handle.clone() }; (identity.did.clone(), handle) }) .collect(); let window_start = window_start(now); let mine_set: HashSet<String> = me_set.union(&bot_set).cloned().collect(); let window_rows: Vec<DailyRow> = db .daily_rows_logical(&format!("{}T00:00:00Z", window_start.format("%Y-%m-%d")))? .into_iter() .filter(|row| mine_set.contains(&row.author_did)) .collect(); let github_homepages = db.github_homepages()?; let chart_projects = chart_projects(db, &window_rows, &handles, &github_homepages)?; let chart_keys: Vec<&str> = chart_projects .iter() .map(|project| project.label.repo_key.as_str()) .collect(); let daily = daily_series( &window_rows, &me_set, &bot_set, &chart_keys, window_start, now, ); let hourly = HourlySeries { projects: hourly_project_rows(&window_rows, &chart_keys), ..hourly_series(&window_rows, &me_set, &bot_set) }; let commit_types = crate::commit_types::binned( &db.subject_rows_logical(&format!("{}T00:00:00Z", window_start.format("%Y-%m-%d")))?, &mine_set, window_start, now.date_naive(), 30, ); let repo_names: HashMap<String, String> = db .repo_by_key()? .into_iter() .map(|(key, repo)| { let owner = handles .get(&repo.owner_did) .cloned() .unwrap_or(repo.owner_did); (key, format!("{owner}/{}", repo.name)) }) .collect(); let land_gap = crate::land_gap::summarize( &db.land_rows_logical(&format!("{}T00:00:00Z", window_start.format("%Y-%m-%d")))?, &mine_set, &repo_names, window_start, now.date_naive(), &[30, 90], ); let burn = Some(crate::burn::series(&db.scrobble_usage()?, window_start)) .filter(|burn| !burn.days.is_empty()); let repos = repo_views( db, &personal_dids, &github_dids, &bot_dids, &since_30, &handles, &github_homepages, )?; let projects = project_views(db, &all_mine, now, &handles, &github_homepages)?; let project_timelines = project_timelines( db, &all_mine, window_start, now.date_naive(), &handles, &github_homepages, )?; let recent = recent_views(db, &identities, &all_mine)?; let day_shards = { let metadata = db.repo_by_key()?; crate::day_shards::build( &db.day_rows_logical(&format!("{}T00:00:00Z", window_start.format("%Y-%m-%d")))?, &me_set.union(&bot_set).cloned().collect(), window_start, now.date_naive(), |repo_key| project_label(repo_key, &metadata, &handles, &github_homepages), ) }; let coverage = db.repo_coverage()?; let (repos_total, repos_scanned) = global .map(|value| (value.repos_total, value.repos_reached)) .unwrap_or((coverage.repos_total, coverage.repos_scanned)); let coverage_pct = percent(repos_scanned, repos_total); let backfill_complete = repos_total > 0 && repos_scanned * 100 >= repos_total * 95; let errors = db .error_summary(12)? .into_iter() .map(|(repo, error)| ErrorView { repo, error }) .collect(); let github_accounts: Vec<GithubAccountStatus> = crawl .github .iter() .map(|report| GithubAccountStatus { label: report.label.clone(), login: report.login.clone(), redacted: report.redacted, repos: report.repos, repos_scanned: report.repos_scanned, commits_new: report.commits_new, errors: report.errors, }) .collect(); let github = GithubStatus { configured: crawl.github_configured, repos: github_accounts.iter().map(|account| account.repos).sum(), repos_scanned: github_accounts .iter() .map(|account| account.repos_scanned) .sum(), commits_new: github_accounts .iter() .map(|account| account.commits_new) .sum(), errors: github_accounts.iter().map(|account| account.errors).sum(), accounts: github_accounts, }; let work_inbox = crawl.work_inbox.as_ref().map(|report| WorkInboxStatus { source: report.source_kind.clone(), generated_at: report.generated_at.clone(), commits_read: report.commits_read, commits_new: report.commits_new, repos: report.repos, });
Ok(SiteData { summary: Summary { generated_at: now_string(), title: config.site_title.clone(), timezone: config.display_timezone.clone(), activity: activity_feed(config), site_url: config.site_url.clone(), me_tangled, me_github, me_total, bots_tangled, everything_mine, tangled_total, tangled_total_365, share_available: global_available, global_summary_at: global_at.clone(), global_summary_stale: global_stale, global_partial, share_me: percent(share_me_all, tangled_total), share_all: percent(share_me_all + share_bots_all, tangled_total), share_me_365: percent(share_me_365, tangled_total_365), share_all_365: percent(share_me_365 + share_bots_365, tangled_total_365), share_periods, // The line's last point equals the 30d period: both are the 30 // dates ending on the snapshot's day. share_trend: snapshot .map(|value| value.daily_share(share_end.date_naive(), 30)) .unwrap_or_default(), repos_total, repos_scanned, coverage_pct, backfill_complete, prior_claim: PriorClaim { percent: 32.6, date: "2026-09-04", url: "https://permadeath.com/3mupnu5nc522m", note: "the earlier self-reported share; the later PR-patch census measured 1.1% all-time and 14.3% in August 2026", }, windows, github_configured: crawl.github_configured, methodology: methodology(config.branch_scope), }, identities: identity_views, chart_projects, daily, hourly, commit_types, land_gap, burn, repos, projects, project_timelines, recent, status: StatusView { repos_total: coverage.repos_total, repos_scanned: coverage.repos_scanned, repos_failed: coverage.repos_failed, branches_total: coverage.branches_total, branches_indexed: coverage.branches_indexed, bots_known: bots.len(), bots_active: bot_active.len(), last_crawl_started: db.get_meta("last_crawl_started")?, last_crawl_finished: db.get_meta("last_crawl_finished")?, global_summary_at: global_at, global_summary_stale: global_stale, budget_exhausted: crawl.budget_exhausted, scope: crawl.scope.clone(), queue_this_run: crawl.repos_queued, commits_seen_this_run: crawl.scan.commits_seen, commits_new_this_run: crawl.scan.commits_new, errors, github, work_inbox, }, day_shards, })}
/// Colours for the projects the date chart and the wheel stack, bottom up./// Neighbours in this order pass the palette validator in both themes; it/// is the validated identity order without its second green, which sat/// closest to another colour. "Other" goes on top in the muted grey.const PROJECT_COLORS: [&str; 6] = [ "series-red", "series-1", "series-3", "series-4", "series-5", "series-7",];
/// Where each charted project's colour slot is kept between builds.const PROJECT_COLORS_META: &str = "chart_project_colors";
/// The busiest projects of the year window, one per colour, in colour/// order. The set is the year's so a period picker never recolours the/// charts, and a project keeps last build's colour while it stays in it.fn chart_projects( db: &Db, rows: &[DailyRow], handles: &HashMap<String, String>, github_homepages: &HashMap<String, String>,) -> Result<Vec<ChartProject>> { let mut totals: HashMap<&str, u64> = HashMap::new(); for row in rows { *totals.entry(row.repo_key.as_str()).or_insert(0) += 1; } let mut ranked: Vec<(&str, u64)> = totals.iter().map(|(key, n)| (*key, *n)).collect(); ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0))); ranked.truncate(PROJECT_COLORS.len()); let ranked: Vec<&str> = ranked.into_iter().map(|(key, _)| key).collect();
let previous: HashMap<String, usize> = db .get_meta(PROJECT_COLORS_META)? .and_then(|value| serde_json::from_str(&value).ok()) .unwrap_or_default(); let slots = color_slots(&ranked, &previous, PROJECT_COLORS.len()); let kept: BTreeMap<&str, usize> = slots .iter() .enumerate() .filter_map(|(slot, key)| key.map(|key| (key, slot))) .collect(); db.set_meta(PROJECT_COLORS_META, &serde_json::to_string(&kept)?)?;
let metadata = db.repo_by_key()?; Ok(slots .into_iter() .enumerate() .filter_map(|(slot, key)| { let key = key?; Some(ChartProject { label: project_label(key, &metadata, handles, github_homepages), color: PROJECT_COLORS[slot].to_string(), total: totals[key], }) }) .collect())}
/// A colour slot for each of `ranked`: the one it had before if that is/// still free, otherwise the lowest free one, busiest first.fn color_slots<'a>( ranked: &[&'a str], previous: &HashMap<String, usize>, slots: usize,) -> Vec<Option<&'a str>> { let mut out: Vec<Option<&str>> = vec![None; slots]; for &key in ranked { if let Some(&slot) = previous.get(key) && slot < slots && out[slot].is_none() { out[slot] = Some(key); } } for &key in ranked { if out.contains(&Some(key)) { continue; } if let Some(free) = out.iter().position(Option::is_none) { out[free] = Some(key); } } out}
/// One count per charted project for `row`'s project, then the rest.fn project_column(keys: &[&str], row: &DailyRow) -> usize { keys.iter() .position(|key| *key == row.repo_key) .unwrap_or(keys.len())}
fn daily_series( rows: &[DailyRow], identities: &HashSet<String>, bots: &HashSet<String>, keys: &[&str], start: chrono::NaiveDate, now: chrono::DateTime<Utc>,) -> Vec<DailyPoint> { let end = now.date_naive(); let mut days: BTreeMap<String, DailyPoint> = BTreeMap::new(); let mut day = start; while day <= end { let key = day.format("%Y-%m-%d").to_string(); days.insert( key.clone(), DailyPoint { date: key, total: 0, identities: BTreeMap::new(), bots: 0, projects: Vec::new(), }, ); let Some(next) = day.succ_opt() else { break }; day = next; }
for row in rows { let Some(date) = row.authored_at.get(0..10) else { continue; }; let Some(point) = days.get_mut(date) else { continue; }; if identities.contains(&row.author_did) { *point.identities.entry(row.author_did.clone()).or_insert(0) += 1; } else if bots.contains(&row.author_did) { point.bots += 1; } else { // Someone else's commit. It belongs in the global denominator, // not in the person's activity chart. continue; } if point.projects.is_empty() { point.projects = vec![0; keys.len() + 1]; } point.projects[project_column(keys, row)] += 1; point.total += 1; }
days.into_values().collect()}
/// Identity columns per hour; `projects` is filled separately.fn hourly_series( rows: &[DailyRow], identities: &HashSet<String>, bots: &HashSet<String>,) -> HourlySeries { let mut series: Vec<String> = identities.iter().cloned().collect(); series.sort(); series.push("bots".to_string()); let column: HashMap<&str, usize> = series .iter() .enumerate() .map(|(index, key)| (key.as_str(), index)) .collect(); let mut hours: BTreeMap<i64, Vec<i64>> = BTreeMap::new(); for row in rows { let key = if identities.contains(&row.author_did) { row.author_did.as_str() } else if bots.contains(&row.author_did) { "bots" } else { continue; }; let Ok(stamp) = chrono::DateTime::parse_from_rfc3339(&row.authored_at) else { continue; }; let hour = stamp.timestamp().div_euclid(3600); hours.entry(hour).or_insert_with(|| vec![0; series.len()])[column[key]] += 1; } // A column nobody committed under in the window is dropped, so the // browser never lists an empty series. let used: Vec<usize> = (0..series.len()) .filter(|&index| hours.values().any(|counts| counts[index] > 0)) .collect(); HourlySeries { projects: Vec::new(), series: used.iter().map(|&index| series[index].clone()).collect(), rows: hours .into_iter() .map(|(hour, counts)| { std::iter::once(hour) .chain(used.iter().map(|&index| counts[index])) .collect() }) .collect(), }}
fn hourly_project_rows(rows: &[DailyRow], keys: &[&str]) -> Vec<Vec<i64>> { let mut hours: BTreeMap<i64, Vec<i64>> = BTreeMap::new(); for row in rows { let Ok(stamp) = chrono::DateTime::parse_from_rfc3339(&row.authored_at) else { continue; }; let hour = stamp.timestamp().div_euclid(3600); hours.entry(hour).or_insert_with(|| vec![0; keys.len() + 1])[project_column(keys, row)] += 1; } hours .into_iter() .map(|(hour, counts)| std::iter::once(hour).chain(counts).collect()) .collect()}
fn repo_views( db: &Db, personal: &[String], github: &[String], bots: &[String], since_30: &str, handles: &HashMap<String, String>, github_homepages: &HashMap<String, String>,) -> Result<Vec<RepoView>> { let metadata = db.repo_by_key()?; let mut mine_dids = personal.to_vec(); mine_dids.extend(github.iter().cloned()); mine_dids.extend(bots.iter().cloned());
let mut out = Vec::new(); for (repo_key, _) in db.top_repos_for_dids(&mine_dids, 40)? { let (commits, mine) = db.repo_commit_counts(&repo_key, &mine_dids)?; if mine == 0 { continue; } let (name, url, website, owner, knot) = match metadata.get(&repo_key) { Some(repo) => ( repo.name.clone(), repo.url.clone(), repo.website.clone(), repo.owner_did.clone(), repo.knot.clone(), ), None => { let full_name = repo_key .strip_prefix("github:") .unwrap_or(&repo_key) .to_string(); let website = github_homepages.get(&full_name).cloned(); ( full_name.clone(), format!("https://github.com/{full_name}"), website, String::new(), "github".to_string(), ) } }; let full_name = if owner.is_empty() { name.clone() } else { format!( "{}/{}", handles.get(&owner).map(String::as_str).unwrap_or(&owner), name ) }; let last_30 = db.repo_commit_counts_since(&repo_key, &mine_dids, since_30)?; let site = (!repo_key.starts_with("github:redacted:")) .then(|| crate::project_sites::resolve(website.as_deref(), &full_name, &name)) .flatten(); out.push(RepoView { repo_key, name, full_name, url, website, site, owner, knot, commits, mine, last_30, }); } for (repo_key, mine) in db.top_github_repos_for_dids(&mine_dids, 40)? { let (name, url, website, owner, knot) = if let Some(rest) = repo_key.strip_prefix("github:redacted:") { let (label, hash) = match rest.split_once(':') { Some((label, hash)) => (label, hash), None if rest == "personal" => ("personal", ""), None => ("work", rest), }; let name = redacted_repo_name(label, hash); ( name, String::new(), None, String::new(), "github · redacted".to_string(), ) } else if let Some(rest) = repo_key.strip_prefix("github:inbox:") { let full_name = rest.split_once(':').map(|(_, name)| name).unwrap_or(rest); let website = github_homepages.get(full_name).cloned(); ( full_name.to_string(), format!("https://github.com/{full_name}"), website, full_name.split('/').next().unwrap_or("").to_string(), "github".to_string(), ) } else if let Some(full_name) = repo_key.strip_prefix("github:") { let website = github_homepages.get(full_name).cloned(); ( full_name.to_string(), format!("https://github.com/{full_name}"), website, full_name.split('/').next().unwrap_or("").to_string(), "github".to_string(), ) } else { continue; }; let last_30 = db.repo_commit_counts_since(&repo_key, &mine_dids, since_30)?; let full_name = name.clone(); let site = (!repo_key.starts_with("github:redacted:")) .then(|| crate::project_sites::resolve(website.as_deref(), &full_name, &name)) .flatten(); out.push(RepoView { repo_key, name, full_name, url, website, site, owner, knot, commits: mine, mine, last_30, }); } out.sort_by(|a, b| b.mine.cmp(&a.mine).then_with(|| b.commits.cmp(&a.commits))); out.truncate(20); Ok(out)}
pub(crate) fn project_label( repo_key: &str, metadata: &HashMap<String, crate::model::Repo>, handles: &HashMap<String, String>, github_homepages: &HashMap<String, String>,) -> ProjectLabel { let label = |name: String, full_name: String, url: String, website: Option<String>, knot: &str| { ProjectLabel { repo_key: repo_key.to_string(), site: crate::project_sites::resolve(website.as_deref(), &full_name, &name), name, full_name, url, website, knot: knot.to_string(), redacted: false, } }; if let Some(rest) = repo_key.strip_prefix("github:redacted:") { let (kind, hash) = match rest.split_once(':') { Some((kind, hash)) => (kind, hash), None if rest == "personal" => ("personal", ""), None => ("work", rest), }; let name = redacted_repo_name(kind, hash); return ProjectLabel { redacted: true, site: None, ..label(name.clone(), name, String::new(), None, "github · redacted") }; } let github = repo_key .strip_prefix("github:inbox:") .map(|rest| rest.split_once(':').map(|(_, name)| name).unwrap_or(rest)) .or_else(|| repo_key.strip_prefix("github:")); if let Some(full_name) = github { return label( full_name.to_string(), full_name.to_string(), format!("https://github.com/{full_name}"), github_homepages.get(full_name).cloned(), "github", ); } match metadata.get(repo_key) { Some(repo) => { let owner = handles .get(&repo.owner_did) .map(String::as_str) .unwrap_or(&repo.owner_did); label( repo.name.clone(), format!("{owner}/{}", repo.name), repo.url.clone(), repo.website.clone(), &repo.knot, ) } None => label( repo_key.to_string(), repo_key.to_string(), String::new(), None, "", ), }}
/// Projects in the sidebar beside the activity matrix.const PROJECTS_SHOWN: usize = 5;
/// The `PROJECTS_SHOWN` busiest projects of each period (30 days, 90 days,/// a year, all time), each listed once with its count for every period, in/// 30-day order first.fn project_views( db: &Db, dids: &[String], now: chrono::DateTime<Utc>, handles: &HashMap<String, String>, github_homepages: &HashMap<String, String>,) -> Result<Vec<ProjectView>> { let metadata = db.repo_by_key()?; let since_30 = period_start(now, 30); let periods = [ since_30.clone(), period_start(now, 90), period_start(now, 365), String::new(), ]; let mut counts: HashMap<String, [Option<u64>; 4]> = HashMap::new(); let mut shown: Vec<String> = Vec::new(); for (period, since) in periods.iter().enumerate() { let mut ranked: Vec<(String, u64)> = Vec::new(); ranked.extend(db.top_repos_for_dids_since(dids, since, "tangled", 40)?); ranked.extend(db.top_repos_for_dids_since(dids, since, "github", 40)?); ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); for (rank, (repo_key, count)) in ranked.into_iter().enumerate() { counts.entry(repo_key.clone()).or_default()[period] = Some(count); if rank < PROJECTS_SHOWN && !shown.contains(&repo_key) { shown.push(repo_key); } } }
let mut out = Vec::new(); for repo_key in shown { // A project outside a period's top 40 is counted directly. let mut commits = [0u64; 4]; for (period, since) in periods.iter().enumerate() { commits[period] = match counts[&repo_key][period] { Some(count) => count, None => db.repo_commit_counts_between(&repo_key, dids, since, "9999")?, }; } let prior_90 = db.repo_commit_counts_between(&repo_key, dids, &period_start(now, 120), &since_30)?; out.push(ProjectView { label: project_label(&repo_key, &metadata, handles, github_homepages), commits: PeriodCounts { days_30: commits[0], days_90: commits[1], year: commits[2], all: commits[3], }, prior_90, weekly: Vec::new(), }); } // Twelve weeks for each listed project; the last window ends now. let end = stamp_days_ago(now + Duration::seconds(1), 0); for project in &mut out { project.weekly = (0..12i64) .map(|week| { let from = stamp_days_ago(now, 7 * (12 - week)); let to = if week == 11 { end.clone() } else { stamp_days_ago(now, 7 * (11 - week)) }; db.repo_commit_counts_between(&project.label.repo_key, dids, &from, &to) }) .collect::<Result<_>>()?; } Ok(out)}
/// Daily commits for the busiest projects of the year window, labelled/// like the sidebar.fn project_timelines( db: &Db, dids: &[String], start: chrono::NaiveDate, end: chrono::NaiveDate, handles: &HashMap<String, String>, github_homepages: &HashMap<String, String>,) -> Result<crate::project_timelines::ProjectTimelines> { let metadata = db.repo_by_key()?; let rows = db.project_day_rows_logical(dids, &format!("{start}T00:00:00Z"))?; let projects = crate::project_timelines::daily_series(&rows, start, end, crate::project_timelines::LANES) .into_iter() .map(|series| crate::project_timelines::Timeline { label: project_label(&series.repo_key, &metadata, handles, github_homepages), total: series.total, first: series.first, daily: series.daily, }) .collect(); Ok(crate::project_timelines::ProjectTimelines { start: start.to_string(), end: end.to_string(), projects, })}
fn recent_views( db: &Db, identities: &[crate::model::Identity], dids: &[String],) -> Result<Vec<RecentView>> { let colors: std::collections::HashMap<&str, (String, &str)> = identities .iter() .map(|identity| { let handle = if identity.kind == IdentityKind::Github { github_display_handle(&identity.did, &identity.handle) } else { identity.handle.clone() }; (identity.did.as_str(), (handle, identity.color.as_str())) }) .collect(); let metadata = db.repo_by_key()?; let mut out = Vec::new(); for commit in db.recent_commits_for_dids_all(dids, 60)? { let (handle, color) = colors .get(commit.author_did.as_str()) .map(|(handle, color)| (handle.as_str(), *color)) .unwrap_or(("someone else", "#94a3b8")); let (repo, repo_url) = if let Some(rest) = commit.repo_key.strip_prefix("github:inbox:") { let full_name = rest.split_once(':').map(|(_, name)| name).unwrap_or(rest); ( full_name.to_string(), format!("https://github.com/{full_name}"), ) } else if let Some(full_name) = commit.repo_key.strip_prefix("github:") { ( full_name.to_string(), format!("https://github.com/{full_name}"), ) } else { match metadata.get(&commit.repo_key) { Some(repo) => (repo.name.clone(), repo.url.clone()), None => ( commit .repo_key .strip_prefix("tangled:") .unwrap_or(&commit.repo_key) .to_string(), String::new(), ), } }; out.push(RecentView { short: commit.sha.chars().take(10).collect(), sha: commit.sha, author_did: commit.author_did, author: handle.to_string(), color: color.to_string(), repo_key: commit.repo_key, repo, url: if commit.url.is_empty() { repo_url } else { commit.url }, authored_at: commit.authored_at, message: commit.message, }); if out.len() >= 40 { break; } } Ok(out)}
fn share_period( db: &Db, global: Option<&GlobalSummary>, global_fresh: bool, personal: &[String], bots: &[String], since: Option<&str>, snapshot: Option<&GlobalSummary>,) -> Result<SharePeriod> { let (me_tangled, bots_tangled, tangled_total) = match snapshot { Some(value) => ( value.owner_total(since), value.agent_total(since), value.total(since), ), None => ( db.count_logical_commits(personal, Some("tangled"), since)?, db.count_logical_commits(bots, Some("tangled"), since)?, if global_fresh { global.map(|value| value.total(since)).unwrap_or(0) } else { 0 }, ), }; let objects_me_tangled = db.count_commits(personal, Some("tangled"), since)?; let objects_bots_tangled = db.count_commits(bots, Some("tangled"), since)?; Ok(SharePeriod { me_tangled, bots_tangled, tangled_total, objects_me_tangled, objects_bots_tangled, // The global summary publishes logical counts only. The local // object count is owned-only, so it must not be presented as a global // object count. objects_tangled_total: 0, share_me: percent(me_tangled, tangled_total), share_all: percent(me_tangled + bots_tangled, tangled_total), })}
fn percent(numerator: u64, denominator: u64) -> f64 { if denominator == 0 { 0.0 } else { (numerator as f64 / denominator as f64) * 100.0 }}
fn github_display_handle(did: &str, fallback: &str) -> String { if did.starts_with("github-work:") || did == "github:work" || did == "github-inbox:work" { "work GitHub".to_string() } else if did.starts_with("github:") { "personal GitHub".to_string() } else { fallback.to_string() }}
fn redacted_repo_name(label: &str, hash: &str) -> String { let owner = if label == "work" { "work GitHub" } else { "personal GitHub" }; if hash.starts_with("h_") { format!("{owner} · {hash}") } else { owner.to_string() }}
/// The start of a `days`-day period ending on `now`'s UTC date: midnight of/// that date less `days - 1`, so 30 is today and the 29 dates before it, as/// the page's charts show 30 dates for "30 days".fn period_start(now: chrono::DateTime<Utc>, days: i64) -> String { format!( "{}T00:00:00Z", now.date_naive() - Duration::days(days.max(1) - 1) )}
fn stamp_days_ago(now: chrono::DateTime<Utc>, days: i64) -> String { (now - Duration::days(days)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)}
fn methodology(scope: crate::db::BranchScope) -> String { let reach = match scope { crate::db::BranchScope::Default => { "A commit counts once its repository's default branch reaches it: merged \ work. Commits only on other branches, rebased or abandoned ones included, don't \ count. It counts once across repositories, pushes, forges, and rebases." } crate::db::BranchScope::All => { "A commit is reachable from at least one branch of a tracked repository and \ counts once across branches, pushes, forges, and rebases." } }; format!( "Definition: a logical change is a distinct Change-Id when the commit carries one \ (atgc writes these trailers), otherwise a distinct Git object ID (SHA). A Change-Id \ survives a rebase, so the rewritten object collapses into the same logical change. \ Exact resubmits are already excluded because the SHA does not change; the crawler is \ not counting Tangled's native ref-update announcements. {reach} Merge commits count once. In the share, a rebase without a Change-Id \ counts once when its author, author time and subject match; elsewhere it remains a \ separate object. Tags are not counted separately. Attribution \ uses the Git author (Tangled stores the author's DID; GitHub commits are filtered by \ the authenticated login), and dates are author dates in UTC. The dashboard shows the \ logical count and, for context, the raw unique Git-object count. The denominator is \ the global crawl summary: distinct logical changes per UTC author day across the \ public Tangled repositories it reached. The hourly site uses that summary and hides \ the share when it is missing or older than the configured maximum age. The share covers all \ of your Tangled identities, plus your bots, over the selected period and across all \ repositories. GitHub is tracked separately and is not part of the Tangled denominator. \ Bot identities are didbot accounts whose DID document names one of the personal DIDs \ as operator." )}
#[cfg(test)]mod tests { use super::*;
fn row(did: &str, at: &str, repo: &str) -> DailyRow { DailyRow { author_did: did.into(), authored_at: at.into(), repo_key: repo.into(), } }
#[test] fn the_hourly_series_buckets_by_utc_hour_and_drops_unused_columns() { let rows = [ row("did:plc:me", "2026-10-25T14:05:00Z", "tangled:a"), row("did:plc:me", "2026-10-25T14:59:59Z", "tangled:a"), row("did:web:bot", "2026-10-25T14:30:00Z", "tangled:a"), row("did:plc:me", "2026-10-25T15:00:00Z", "tangled:a"), row("did:plc:stranger", "2026-10-25T14:10:00Z", "tangled:a"), ]; let me: HashSet<String> = ["did:plc:me", "did:plc:quiet"].map(String::from).into(); let bots: HashSet<String> = ["did:web:bot".to_string()].into(); let hourly = hourly_series(&rows, &me, &bots); assert_eq!(hourly.series, ["did:plc:me", "bots"]); let hour = chrono::DateTime::parse_from_rfc3339("2026-10-25T14:00:00Z") .unwrap() .timestamp() / 3600; assert_eq!(hourly.rows, [vec![hour, 2, 1], vec![hour + 1, 1, 0]]); }
#[test] fn the_hourly_series_buckets_by_utc_hour_and_project() { let rows = [ row("did:plc:me", "2026-10-25T14:05:00Z", "tangled:a"), row("did:plc:me", "2026-10-25T14:59:59Z", "tangled:b"), row("did:web:bot", "2026-10-25T14:30:00Z", "tangled:a"), row("did:plc:me", "2026-10-25T15:00:00Z", "github:me/tool"), ]; let projects = hourly_project_rows(&rows, &["tangled:a", "tangled:b"]); let hour = chrono::DateTime::parse_from_rfc3339("2026-10-25T14:00:00Z") .unwrap() .timestamp() / 3600; // The owner and an agent in the same project share its column; a // project outside the chart goes to the last one. assert_eq!(projects, [vec![hour, 2, 1, 0], vec![hour + 1, 0, 0, 1]]); }
#[test] fn daily_points_break_the_total_down_by_identity_and_by_project() { let rows = [ row("did:plc:me", "2026-10-02T09:00:00Z", "tangled:a"), row("did:web:bot", "2026-10-02T10:00:00Z", "tangled:a"), row("did:plc:me", "2026-10-02T11:00:00Z", "tangled:z"), row("did:web:bot", "2026-10-03T23:59:59Z", "tangled:b"), row("did:plc:stranger", "2026-10-03T12:00:00Z", "tangled:b"), // Before the window. row("did:plc:me", "2026-09-30T12:00:00Z", "tangled:a"), ]; let start = chrono::NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(); let now = chrono::DateTime::parse_from_rfc3339("2026-10-04T12:00:00Z") .unwrap() .with_timezone(&Utc); let me: HashSet<String> = ["did:plc:me".to_string()].into(); let bots: HashSet<String> = ["did:web:bot".to_string()].into(); let daily = daily_series(&rows, &me, &bots, &["tangled:a", "tangled:b"], start, now); let view: Vec<_> = daily .iter() .map(|point| { ( point.date.as_str(), point.total, point.identities.get("did:plc:me").copied().unwrap_or(0), point.bots, point.projects.clone(), ) }) .collect(); // Both breakdowns add up to the total; a stranger counts in neither. assert_eq!( view, [ ("2026-10-01", 0, 0, 0, vec![]), ("2026-10-02", 3, 2, 1, vec![2, 0, 1]), ("2026-10-03", 1, 0, 1, vec![0, 1, 0]), ("2026-10-04", 0, 0, 0, vec![]), ] ); // A quiet day costs no breakdown in the payload. let quiet = serde_json::to_string(&daily[0]).unwrap(); assert_eq!(quiet, r#"{"date":"2026-10-01","total":0}"#); }
#[test] fn a_project_keeps_its_colour_slot_whatever_its_rank() { let previous: HashMap<String, usize> = [ ("b".to_string(), 0), ("a".to_string(), 2), ("gone".to_string(), 1), ] .into(); // "a" is now the busiest and "b" second; both keep their slots. The // newcomer takes the lowest free one, and a slot out of range is // treated as free. assert_eq!( color_slots(&["a", "b", "new"], &previous, 3), [Some("b"), Some("new"), Some("a")] ); let stale: HashMap<String, usize> = [("a".to_string(), 9)].into(); assert_eq!(color_slots(&["a"], &stale, 2), [Some("a"), None]); assert_eq!( color_slots(&["a", "b"], &HashMap::new(), 3), [Some("a"), Some("b"), None] ); }
#[test] fn chart_projects_are_the_busiest_and_keep_colours_between_builds() { let db = Db::in_memory().unwrap(); let rows = |counts: &[(&str, usize)]| -> Vec<DailyRow> { counts .iter() .flat_map(|(repo, n)| { (0..*n).map(|_| row("did:plc:me", "2026-10-02T09:00:00Z", repo)) }) .collect() }; let build = |counts: &[(&str, usize)]| { chart_projects(&db, &rows(counts), &HashMap::new(), &HashMap::new()) .unwrap() .into_iter() .map(|project| (project.label.full_name, project.color, project.total)) .collect::<Vec<_>>() }; let mut year: Vec<(&str, usize)> = vec![ ("github:me/tool", 9), ("github:redacted:work:h_ab12", 8), ("tangled:c", 7), ("tangled:d", 6), ("tangled:e", 5), ("tangled:f", 4), ("tangled:small", 1), ]; let first = build(&year); assert_eq!(first.len(), PROJECT_COLORS.len()); assert_eq!( first[..2], [ ("me/tool".to_string(), "series-red".to_string(), 9), ( "work GitHub · h_ab12".to_string(), "series-1".to_string(), 8 ), ] ); assert!(!first.iter().any(|(name, ..)| name == "tangled:small"));
// The ranks reverse, so the old busiest is now the quietest, and a // newcomer pushes it out: every project still charted keeps its // colour, and the newcomer takes the freed one. year.iter_mut().for_each(|(_, n)| *n = 20 - *n); year.push(("tangled:new", 30)); year.retain(|(repo, _)| *repo != "tangled:small"); let second = build(&year); let colour = |list: &[(String, String, u64)], name: &str| { list.iter() .find(|(full_name, ..)| full_name == name) .map(|(_, color, _)| color.clone()) }; for name in [ "work GitHub · h_ab12", "tangled:c", "tangled:d", "tangled:e", "tangled:f", ] { assert_eq!(colour(&first, name), colour(&second, name), "{name}"); } assert_eq!(colour(&second, "me/tool"), None); assert_eq!( colour(&second, "tangled:new").as_deref(), Some("series-red") ); // Stack order is colour order, whatever the ranks. let order: Vec<_> = second.iter().map(|(_, color, _)| color.as_str()).collect(); assert_eq!(order, PROJECT_COLORS); }
#[test] fn a_period_is_its_number_of_dates_ending_today() { let now = chrono::DateTime::parse_from_rfc3339("2026-10-25T12:00:00Z") .unwrap() .with_timezone(&Utc); // Oct 25 and the 29 dates before it. assert_eq!(period_start(now, 30), "2026-09-26T00:00:00Z"); assert_eq!(period_start(now, 1), "2026-10-25T00:00:00Z"); assert_eq!(period_start(now, 365), "2025-10-26T00:00:00Z"); }
#[test] fn each_project_carries_twelve_weeks_ending_now() { let db = Db::in_memory().unwrap(); let now = chrono::DateTime::parse_from_rfc3339("2026-10-25T12:00:00Z") .unwrap() .with_timezone(&Utc); for (sha, days_ago) in [("a", 1), ("b", 2), ("c", 9), ("d", 80), ("e", 90)] { let at = stamp_days_ago(now, days_ago); db.insert_commit( &crate::model::Commit { sha: sha.into(), change_id: String::new(), author_did: "did:plc:me".into(), author_email: String::new(), author_name: String::new(), authored_at: at.clone(), committed_at: at, message: String::new(), }, &crate::model::CommitRef { sha: sha.into(), source: "tangled".into(), repo_key: "tangled:did:plc:repo".into(), url: String::new(), on_default: true, }, ) .unwrap(); } let me = ["did:plc:me".to_string()]; let projects = project_views(&db, &me, now, &HashMap::new(), &HashMap::new()).unwrap(); // 80 days back is in the oldest week (84 to 77); 90 is before it. assert_eq!(projects[0].weekly, [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2]); // 90 days back is the 91st date, outside "90 days". assert_eq!( projects[0].commits, PeriodCounts { days_30: 3, days_90: 4, year: 5, all: 5 } ); }
#[test] fn timelines_count_logical_changes_per_project_by_day() { let db = Db::in_memory().unwrap(); let add = |sha: &str, change_id: &str, did: &str, at: &str, source: &str, repo: &str| { db.insert_commit( &crate::model::Commit { sha: sha.into(), change_id: change_id.into(), author_did: did.into(), author_email: String::new(), author_name: String::new(), authored_at: at.into(), committed_at: at.into(), message: String::new(), }, &crate::model::CommitRef { sha: sha.into(), source: source.into(), repo_key: repo.into(), url: String::new(), on_default: true, }, ) .unwrap(); }; let tangled = "tangled:did:plc:repo"; // A resubmitted change (two SHAs, one Change-Id) counts once. add( "a1", "I1", "did:plc:me", "2026-10-02T09:00:00Z", "tangled", tangled, ); add( "a2", "I1", "did:plc:me", "2026-10-02T10:00:00Z", "tangled", tangled, ); add( "b", "", "did:web:bot", "2026-10-04T09:00:00Z", "tangled", tangled, ); add( "s", "", "did:plc:stranger", "2026-10-03T09:00:00Z", "tangled", tangled, ); add( "g", "", "github:me", "2026-10-03T09:00:00Z", "github", "github:me/tool", ); add( "w", "", "github:me", "2026-10-05T09:00:00Z", "github", "github:redacted:work:h_ab12", ); // Before the window. add( "old", "", "did:plc:me", "2026-09-01T09:00:00Z", "tangled", tangled, ); let mine = ["did:plc:me", "did:web:bot", "github:me"].map(String::from); let day = |value: &str| chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap(); let timelines = project_timelines( &db, &mine, day("2026-10-01"), day("2026-10-10"), &HashMap::new(), &HashMap::new(), ) .unwrap(); assert_eq!( (timelines.start.as_str(), timelines.end.as_str()), ("2026-10-01", "2026-10-10") ); let lanes: Vec<_> = timelines .projects .iter() .map(|lane| { ( lane.label.full_name.as_str(), lane.label.redacted, lane.first, lane.daily.clone(), ) }) .collect(); assert_eq!( lanes, [ (tangled, false, 1, vec![1, 0, 1]), ("me/tool", false, 2, vec![1]), ("work GitHub · h_ab12", true, 4, vec![1]), ] ); assert_eq!(timelines.projects[0].total, 2); }
#[test] fn the_sidebar_keeps_the_five_busiest_projects() { let db = Db::in_memory().unwrap(); let now = chrono::DateTime::parse_from_rfc3339("2026-10-25T12:00:00Z") .unwrap() .with_timezone(&Utc); // Repo n gets n commits, so the ranking is repo 7 down to repo 1. for repo in 1..=7 { for i in 0..repo { let sha = format!("{repo}-{i}"); let at = stamp_days_ago(now, 1 + i); db.insert_commit( &crate::model::Commit { sha: sha.clone(), change_id: String::new(), author_did: "did:plc:me".into(), author_email: String::new(), author_name: String::new(), authored_at: at.clone(), committed_at: at, message: String::new(), }, &crate::model::CommitRef { sha, source: "tangled".into(), repo_key: format!("tangled:did:plc:repo{repo}"), url: String::new(), on_default: true, }, ) .unwrap(); } } let me = ["did:plc:me".to_string()]; let projects = project_views(&db, &me, now, &HashMap::new(), &HashMap::new()).unwrap(); let counts: Vec<u64> = projects .iter() .map(|project| project.commits.days_30) .collect(); assert_eq!(counts, [7, 6, 5, 4, 3]); }
#[test] fn projects_cover_the_busiest_of_every_period() { let db = Db::in_memory().unwrap(); let now = chrono::DateTime::parse_from_rfc3339("2026-10-25T12:00:00Z") .unwrap() .with_timezone(&Utc); let commit = |sha: String, repo: String, days_ago: i64| { let at = stamp_days_ago(now, days_ago); db.insert_commit( &crate::model::Commit { sha: sha.clone(), change_id: String::new(), author_did: "did:plc:me".into(), author_email: String::new(), author_name: String::new(), authored_at: at.clone(), committed_at: at, message: String::new(), }, &crate::model::CommitRef { sha, source: "tangled".into(), repo_key: repo, url: String::new(), on_default: true, }, ) .unwrap(); }; // One more repo than the sidebar shows with a recent change each, // and one with five from half a year ago: the 30-day list leaves it // out, a year's includes it. for repo in 0..=PROJECTS_SHOWN { commit(format!("r{repo}"), format!("tangled:recent{repo:02}"), 2); } for change in 0..5 { commit(format!("o{change}"), "tangled:old".into(), 180); } let me = ["did:plc:me".to_string()]; let projects = project_views(&db, &me, now, &HashMap::new(), &HashMap::new()).unwrap(); let old = projects .iter() .find(|project| project.label.repo_key == "tangled:old") .expect("the year's busiest project is listed"); assert_eq!( old.commits, PeriodCounts { days_30: 0, days_90: 0, year: 5, all: 5 } ); // The 30-day list takes the first recent repos; the last one ties, // sorts after them, and makes no list. assert_eq!(projects.len(), PROJECTS_SHOWN + 1); assert_eq!(projects[0].commits.days_30, 1); }
#[test] fn a_snapshot_supplies_both_sides_of_the_share() { // The live database holds a commit the crawl never saw; with a // snapshot, neither side of the share includes it. let db = Db::in_memory().unwrap(); db.insert_commit( &crate::model::Commit { sha: "late".into(), change_id: String::new(), author_did: "did:plc:me".into(), author_email: String::new(), author_name: String::new(), authored_at: "2026-10-21T12:00:00Z".into(), committed_at: "2026-10-21T12:00:00Z".into(), message: String::new(), }, &crate::model::CommitRef { sha: "late".into(), source: "tangled".into(), repo_key: "tangled:did:plc:repo".into(), url: String::new(), on_default: true, }, ) .unwrap(); let summary: GlobalSummary = serde_json::from_str( r#"{"version":1,"generated_at":"2026-10-20T00:00:00Z","repos_reached":1,"repos_total":1, "days":{"2026-10-01":40,"2026-10-19":60}, "owner_days":{"2026-10-01":1,"2026-10-19":2}, "agent_days":{"2026-10-19":1}}"#, ) .unwrap(); let me = ["did:plc:me".to_string()]; let since = Some("2026-10-10T00:00:00Z"); let period = share_period(&db, Some(&summary), true, &me, &[], since, Some(&summary)).unwrap(); assert_eq!( (period.me_tangled, period.bots_tangled, period.tangled_total), (2, 1, 60) ); let live = share_period(&db, Some(&summary), true, &me, &[], since, None).unwrap(); assert_eq!((live.me_tangled, live.tangled_total), (1, 60)); }
#[test] fn the_activity_feed_reads_the_first_personal_identity() { let mut config = crate::config::Config::from_env().unwrap(); config.activity_url = Some("https://vibescrobble.com".into()); config.activity_text = false; let first = config.personal[0].did.clone(); assert_eq!( activity_feed(&config), Some(ActivityFeed { url: "https://vibescrobble.com".into(), operator: first, text: false, }) ); config.activity_url = None; assert_eq!(activity_feed(&config), None); }
#[test] fn github_labels_do_not_expose_logins() { assert_eq!( github_display_handle("github:jmeickle", "jmeickle"), "personal GitHub" ); assert_eq!( github_display_handle("github-work:redacted", "work (redacted)"), "work GitHub" ); assert_eq!( github_display_handle("github-inbox:work", "work GitHub"), "work GitHub" ); }
#[test] fn project_labels_carry_a_site_but_redacted_ones_never_do() { let repo = |name: &str, website: Option<&str>| crate::model::Repo { repo_did: format!("did:plc:{name}"), owner_did: "did:plc:me".into(), name: name.into(), knot: "knot.example".into(), url: format!("https://tangled.example/me/{name}"), website: website.map(String::from), fork: false, created_at: None, active: true, last_scanned: None, last_error: None, }; let metadata: HashMap<String, crate::model::Repo> = [ ( "tangled:a".to_string(), repo("tool", Some("https://tool.example")), ), ("tangled:b".to_string(), repo("site.example", None)), ("tangled:c".to_string(), repo("plain", None)), ] .into(); let handles: HashMap<String, String> = [("did:plc:me".to_string(), "me.example".to_string())].into(); let homepages: HashMap<String, String> = [ ("org/app".to_string(), "https://app.example".to_string()), ("h_ab12".to_string(), "https://leak.example".to_string()), ] .into(); let site = |key: &str| project_label(key, &metadata, &handles, &homepages).site; assert_eq!(site("tangled:a").as_deref(), Some("https://tool.example")); assert_eq!(site("tangled:b").as_deref(), Some("https://site.example")); assert_eq!(site("tangled:c"), None); assert_eq!( site("github:org/app").as_deref(), Some("https://app.example") ); assert_eq!( site("github:org/site.example").as_deref(), Some("https://site.example") ); for key in [ "github:redacted:work:h_ab12", "github:redacted:personal:h_ab12", ] { let label = project_label(key, &metadata, &handles, &homepages); assert!(label.redacted); assert_eq!(label.site, None, "{key}"); } }
#[test] fn redacted_repositories_get_distinct_names() { assert_eq!(redacted_repo_name("work", "h_ab12"), "work GitHub · h_ab12"); assert_eq!( redacted_repo_name("personal", "h_cd34"), "personal GitHub · h_cd34" ); }}