tracking app

This commit is contained in:
Brandon Thomas committed 2026-10-08 22:49:44 -04:00
commit 5780653c25
47 files changed
+8399

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
*~
/target
**/gen/schemas
*.log
.DS_Store
node_modules/
ui/dev-snapshot.json
Generated
+4867
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
[workspace]
resolver = "2"
members = ["crates/craft-core", "crates/craft-github", "crates/craft-status-app"]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1"
dirs = "7"
thiserror = "2"
chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true
[profile.dev]
opt-level = 1
+60
View File
@@ -0,0 +1,60 @@
# Convenience targets. Tauri CLI resolves the project from crates/craft-status-app.
APP_DIR := crates/craft-status-app
APP_NAME := Craft Status
BUNDLE := target/release/bundle/macos/$(APP_NAME).app
INSTALLED := /Applications/$(APP_NAME).app
AGENT_ID := ai.storyteller.craft-status
AGENT := $(HOME)/Library/LaunchAgents/$(AGENT_ID).plist
.PHONY: dev run build bundle install uninstall test check snapshot preview icons clean
dev: ## tauri dev (live-reloads ui/)
cd $(APP_DIR) && cargo tauri dev
run: ## fast debug binary, no bundling
cargo run -p craft-status-app
build: ## release build + every installer for this OS
cd $(APP_DIR) && cargo tauri build
bundle: ## release .app only (macOS)
cd $(APP_DIR) && cargo tauri build --bundles app
install: bundle ## copy to /Applications, start at login, launch now (macOS)
-pkill -x craft-status-app; sleep 1
rm -rf "$(INSTALLED)" && cp -R "$(BUNDLE)" "$(INSTALLED)"
mkdir -p "$(HOME)/Library/LaunchAgents"
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>' \
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
'<plist version="1.0"><dict>' \
'<key>Label</key><string>$(AGENT_ID)</string>' \
'<key>ProgramArguments</key><array><string>/usr/bin/open</string><string>-a</string><string>$(INSTALLED)</string></array>' \
'<key>RunAtLoad</key><true/>' \
'</dict></plist>' > "$(AGENT)"
open "$(INSTALLED)"
uninstall: ## quit, remove the app and the login item (keeps config + cache)
-pkill -x craft-status-app
rm -rf "$(INSTALLED)" "$(AGENT)"
test: ## Rust + UI unit tests
cargo test --workspace
node --test ui/*.test.mjs
check: ## lints
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all -- --check
snapshot: ## fetch once from GitHub into ui/dev-snapshot.json (prints a table)
cargo run -q -p craft-github --bin craft-fetch -- --snapshot ui/dev-snapshot.json
preview: snapshot ## open the UI in a browser against the dev snapshot
@echo "http://127.0.0.1:8766/index.html (Ctrl+C to stop)"
python3 -m http.server 8766 --bind 127.0.0.1 --directory ui
icons: ## regenerate icons from the SVG sources
cd $(APP_DIR) && cargo tauri icon icons-src/app-icon.svg -o icons && rm -rf icons/android icons/ios
cd $(APP_DIR) && cargo tauri icon icons-src/tray.svg -o /tmp/craft-tray -p 64 && cp /tmp/craft-tray/64x64.png icons/tray.png
clean:
cargo clean
+128
View File
@@ -0,0 +1,128 @@
Craft Repo Status App
=====================
Find out the status of the craft apps: a tray dashboard ("Craft Status") that
polls the storytold `*craft` repositories on GitHub every 5 minutes, keeps a
cache, and tells you what to fix first. Everything runs locally: no server.
- **Rust workspace**
- `craft-core`: the model, release/download accounting, the urgency
heuristic, config, cache and window state. No network or UI dependencies,
and it is unit-tested.
- `craft-github`: the GitHub GraphQL/REST fetcher, plus a `craft-fetch` CLI
that prints a table, for debugging.
- `craft-status-app`: the Tauri 2 shell (poll thread, tray, global
shortcut, window).
- **Plain HTML/CSS/ES modules** in `ui/`. There is no bundler and no Node at
build time. `ui/model.js` is pure and is tested with `node --test`.
## What it shows
Every column sorts. Click a header; click it again to reverse the order. Repos
with no data always sort last.
| Group | Columns |
| --- | --- |
| Fix first | **Urgency** (the sum of the top five open-issue scores) · **Critical** (open issues naming a crash, hang, freeze, data loss, launch failure or security problem) |
| Open | open PRs · open issues · oldest open PR |
| Latest | last commit to `main` · newest issue · newest PR |
| Last 4 h | commits to `main` · PRs opened · issues opened |
| All time | commits on `main` · contributors · issues (open + closed) · PRs (open + closed + merged) |
| Builds | most recent release tag · its publish date · its downloads · downloads across all releases |
- **Builds** are GitHub releases. Drafts are ignored. Prereleases count, and
they carry a `PRE` tag.
- **Downloads** add up every OS and package type: dmg, msi, AppImage, deb,
rpm, flatpak, zips and tarballs. Checksum lists, signatures, `.zsync` delta
files and updater manifests are left out, because machines fetch them, not
people.
- **Fix first** (`⌘2`) ranks open issues across every repo. Each issue shows
the reasons behind its score. Click a row in the Repos table to filter the
list to that repo. Click an issue to open it on GitHub.
- **Freshness**: the header shows when the oldest data on screen was fetched,
and a pill that reads `FRESH`, `REFRESHING` or **`STALE`**. Data is stale
when any repo has gone `stale_minutes` (default 15) without a successful
fetch. A stale row is dimmed and gets an amber dot; a failed row gets a red
dot, and its error appears in the banner. The tray tooltip also says
whether the data is stale.
### How issues are scored
The score is deterministic and simple to read (`crates/craft-core/src/urgency.rs`):
| Signal | Points |
| --- | --- |
| Crash, panic, freeze, hang, data loss, corruption, won't open or launch, blank screen, security | +40 |
| Broken, but not severe ("doesn't work", error, regression…) | +15 |
| `bug` label | +15 |
| Filed by someone outside the team | +10 |
| Reactions ×3 and comments ×2, log-scaled | up to +40 |
| Names the latest build's version | +10 |
| Opened in the last 24 h | +8 |
| Opened in the last 72 h | +4 |
| Feature request, question or docs nit | −25 |
| Untouched for 30 days | −5 |
Issues labelled `wontfix`, `duplicate` or `invalid` are dropped. For each
repo, the 100 newest open issues and the 30 most-commented open issues are
scored.
## Polling and cache
- The app polls every `poll_minutes` (default 5), four repos at a time. Each
repo costs about three GraphQL requests and one REST request: roughly 50 of
the 5,000 GraphQL points GitHub allows per hour per cycle.
- If fewer than 150 points remain, polling slows to every 30 minutes. If
GitHub rate-limits the app, it waits three intervals. If every repo fails
(offline, or just woke from sleep), it retries after one minute.
- Timing follows the wall clock, so a laptop that wakes from sleep polls
within about 15 s.
- `⌘R` or the ⟳ button refreshes immediately.
- The last snapshot is cached in `~/Library/Caches/craft-status/snapshot.json`.
On launch the window shows the cached data at once, marked `STALE` if it's
old. If the cache is less than one interval old, the app skips the poll it
would otherwise run at launch.
- The GitHub token comes from `github_token` in the config. If that is empty,
it comes from `$GITHUB_TOKEN` or `$GH_TOKEN`, and if those are unset, from
`gh auth token`.
## Tray and window
The app behaves like the Todo app:
- It lives in the menu bar, with no Dock icon.
- Left-click the tray icon to show or hide the window. Right-click it to get
Refresh Now, Always on Top, Edit Config… and Quit.
- `⌘⇧G` shows or hides the window from anywhere.
- The window is chrome-less and semi-transparent, and it appears on every
Space. It remembers its position and size in `~/.craft_status_state.toml`.
- `Esc` hides the window.
## Configuration: `~/.craft_status_config.toml`
The app creates this file from
[`craft_status_config.template.toml`](craft_status_config.template.toml) the
first time it launches. It re-reads the file before each poll, so edits to the
repo list or the intervals take effect without a restart.
## Build, install, test
Prerequisites:
- Rust stable
- `cargo install tauri-cli --version "^2"`
- Xcode CLT on macOS
- `gh auth login`, or a token in the config
```sh
make install # release .app → /Applications/Craft Status.app, start at login, launch
make uninstall # quit, remove the app and the login item (keeps config + cache)
make run # debug binary
make test # Rust + UI unit tests
make check # clippy + rustfmt
make snapshot # fetch once, print a table, write ui/dev-snapshot.json
make preview # serve ui/ in a browser against that snapshot (no Tauri needed)
```
To start the app at login, `make install` adds
`~/Library/LaunchAgents/ai.storyteller.craft-status.plist`.
+44
View File
@@ -0,0 +1,44 @@
# Craft Status — ~/.craft_status_config.toml
# The app re-reads this file before every poll; changes apply without a restart
# (window/tray/shortcut changes apply on the next poll or relaunch).
# Repositories to watch: "owner/name" or full GitHub URLs.
repos = [
"storytold/photocraft",
"storytold/lightcraft",
"storytold/filmcraft",
"storytold/designcraft",
"storytold/vectorcraft",
"storytold/pdfcraft",
"storytold/effectcraft",
"storytold/wordcraft",
"storytold/deckcraft",
"storytold/gridcraft",
"storytold/cadcraft",
"storytold/soundcraft",
]
# How often to poll GitHub (minutes, at least 1). Each poll costs roughly
# 3 GraphQL requests + 1 REST request per repo, far below GitHub's 5000/hour.
poll_minutes = 5.0
# Data older than this is flagged STALE in the window (never less than poll_minutes).
stale_minutes = 15.0
# Leave empty to use $GITHUB_TOKEN / $GH_TOKEN, or else `gh auth token`.
github_token = ""
[window]
width = 1340.0
height = 560.0
always_on_top = false
start_hidden = false
opacity = 0.96
[tray]
hide_dock_icon = true # macOS: menu-bar-only app (restart to apply)
visible_on_all_workspaces = true # summon onto the current desktop/space
close_to_tray = true
[shortcuts]
toggle_window = "CmdOrCtrl+Shift+G" # "" disables
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "craft-core"
description = "Model, urgency scoring, config and cache for Craft Status"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
dirs.workspace = true
thiserror.workspace = true
chrono.workspace = true
+65
View File
@@ -0,0 +1,65 @@
//! The last snapshot, persisted so the window has numbers the instant it opens
//! (flagged stale until the next poll lands). Corrupt or missing → empty.
use std::path::{Path, PathBuf};
use crate::model::Snapshot;
use crate::{atomic_write, Error, Result};
pub struct Cache {
path: PathBuf,
}
impl Cache {
/// `~/Library/Caches/craft-status/snapshot.json` (or the platform equivalent).
pub fn default_location() -> Result<Cache> {
let dir = dirs::cache_dir()
.or_else(|| dirs::home_dir().map(|h| h.join(".cache")))
.ok_or(Error::NoHome)?;
Ok(Cache::at(dir.join("craft-status").join("snapshot.json")))
}
pub fn at(path: PathBuf) -> Cache {
Cache { path }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> Option<Snapshot> {
let text = std::fs::read_to_string(&self.path).ok()?;
let mut s: Snapshot = serde_json::from_str(&text).ok()?;
s.refreshing = false;
Some(s)
}
pub fn save(&self, s: &Snapshot) -> Result<()> {
atomic_write(&self.path, &serde_json::to_string(s)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_and_corrupt_fallback() {
let dir = std::env::temp_dir().join(format!("craft-cache-{}", std::process::id()));
let cache = Cache::at(dir.join("s.json"));
assert!(cache.load().is_none());
let mut s = Snapshot {
refreshing: true,
poll_secs: 300,
..Default::default()
};
s.sync_repos(&["o/r".into()]);
cache.save(&s).unwrap();
let back = cache.load().unwrap();
assert!(!back.refreshing, "a loaded cache is never mid-refresh");
assert_eq!(back.repos, s.repos);
std::fs::write(cache.path(), "{not json").unwrap();
assert!(cache.load().is_none());
std::fs::remove_dir_all(&dir).ok();
}
}
+271
View File
@@ -0,0 +1,271 @@
//! `~/.craft_status_config.toml` (created from a commented template on first
//! launch) and `~/.craft_status_state.toml` (remembered window geometry).
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::{atomic_write, Error, Result};
const CONFIG_FILE_NAME: &str = ".craft_status_config.toml";
const STATE_FILE_NAME: &str = ".craft_status_state.toml";
pub const TEMPLATE: &str = include_str!("../../../craft_status_config.template.toml");
pub const DEFAULT_REPOS: &[&str] = &[
"storytold/photocraft",
"storytold/lightcraft",
"storytold/filmcraft",
"storytold/designcraft",
"storytold/vectorcraft",
"storytold/pdfcraft",
"storytold/effectcraft",
"storytold/wordcraft",
"storytold/deckcraft",
"storytold/gridcraft",
"storytold/cadcraft",
"storytold/soundcraft",
];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// `owner/name` or full `https://github.com/owner/name` URLs.
pub repos: Vec<String>,
pub poll_minutes: f64,
pub stale_minutes: f64,
/// Optional. Otherwise `GITHUB_TOKEN` / `GH_TOKEN`, otherwise `gh auth token`.
pub github_token: String,
pub window: WindowConfig,
pub tray: TrayConfig,
pub shortcuts: Shortcuts,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct WindowConfig {
pub width: f64,
pub height: f64,
pub always_on_top: bool,
pub start_hidden: bool,
pub opacity: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct TrayConfig {
pub hide_dock_icon: bool,
pub visible_on_all_workspaces: bool,
pub close_to_tray: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Shortcuts {
pub toggle_window: String,
}
impl Default for Config {
fn default() -> Self {
Config {
repos: DEFAULT_REPOS.iter().map(|s| s.to_string()).collect(),
poll_minutes: 5.0,
stale_minutes: 15.0,
github_token: String::new(),
window: WindowConfig::default(),
tray: TrayConfig::default(),
shortcuts: Shortcuts::default(),
}
}
}
impl Default for WindowConfig {
fn default() -> Self {
WindowConfig {
width: 1340.0,
height: 560.0,
always_on_top: false,
start_hidden: false,
opacity: 0.96,
}
}
}
impl Default for TrayConfig {
fn default() -> Self {
TrayConfig {
hide_dock_icon: true,
visible_on_all_workspaces: true,
close_to_tray: true,
}
}
}
impl Default for Shortcuts {
fn default() -> Self {
Shortcuts {
toggle_window: "CmdOrCtrl+Shift+G".into(),
}
}
}
/// `https://github.com/o/r(.git)(/…)` or `o/r` → `o/r`.
pub fn normalize_repo(s: &str) -> Option<String> {
let s = s.trim();
let s = s
.strip_prefix("https://github.com/")
.or_else(|| s.strip_prefix("http://github.com/"))
.or_else(|| s.strip_prefix("github.com/"))
.unwrap_or(s);
let mut parts = s.split('/').filter(|p| !p.is_empty());
let owner = parts.next()?;
let name = parts.next()?.trim_end_matches(".git");
let ok = |p: &str| {
!p.is_empty()
&& p.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
};
(ok(owner) && ok(name)).then(|| format!("{owner}/{name}"))
}
impl Config {
pub fn path() -> Result<PathBuf> {
Ok(dirs::home_dir()
.ok_or(Error::NoHome)?
.join(CONFIG_FILE_NAME))
}
pub fn parse(text: &str) -> Result<Config> {
let mut c: Config = toml::from_str(text)?;
c.poll_minutes = c.poll_minutes.clamp(1.0, 24.0 * 60.0);
c.stale_minutes = c.stale_minutes.max(c.poll_minutes);
c.window.opacity = c.window.opacity.clamp(0.2, 1.0);
let mut repos: Vec<String> = Vec::new();
for r in c.repos.iter().filter_map(|r| normalize_repo(r)) {
if !repos.iter().any(|x| x.eq_ignore_ascii_case(&r)) {
repos.push(r);
}
}
c.repos = repos;
Ok(c)
}
pub fn load_from(path: &Path) -> Result<Config> {
let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
path: path.display().to_string(),
source,
})?;
Self::parse(&text)
}
/// Load the config, writing the template first if there is none.
pub fn load_or_create() -> Result<Config> {
let path = Self::path()?;
if !path.exists() {
atomic_write(&path, TEMPLATE)?;
}
Self::load_from(&path)
}
pub fn poll_secs(&self) -> i64 {
(self.poll_minutes * 60.0) as i64
}
pub fn stale_secs(&self) -> i64 {
(self.stale_minutes * 60.0) as i64
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct WindowState {
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Serialize, Deserialize, Default)]
#[serde(default)]
struct StateFile {
window: Option<WindowState>,
}
impl WindowState {
pub fn path() -> Result<PathBuf> {
Ok(dirs::home_dir().ok_or(Error::NoHome)?.join(STATE_FILE_NAME))
}
pub fn load() -> Option<WindowState> {
let text = std::fs::read_to_string(Self::path().ok()?).ok()?;
let f: StateFile = toml::from_str(&text).ok()?;
f.window.filter(|w| w.width > 0 && w.height > 0)
}
pub fn save(&self) -> Result<()> {
let text = format!(
"# Remembered by Craft Status (window position and size). Safe to delete.\n{}",
toml::to_string(&StateFile {
window: Some(*self)
})
.unwrap_or_default()
);
atomic_write(&Self::path()?, &text)
}
/// Whether enough of the window to grab is on one of the monitors `(x, y, w, h)`.
pub fn visible_on(&self, monitors: &[(i32, i32, u32, u32)]) -> bool {
const GRAB: i32 = 40;
monitors.iter().any(|&(mx, my, mw, mh)| {
let (mr, mb) = (mx + mw as i32, my + mh as i32);
let (wr, wb) = (self.x + self.width as i32, self.y + self.height as i32);
wr.min(mr) - self.x.max(mx) >= GRAB && wb.min(mb) - self.y.max(my) >= GRAB
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn template_parses_to_defaults() {
assert_eq!(Config::parse(TEMPLATE).unwrap(), Config::default());
}
#[test]
fn empty_file_is_defaults() {
assert_eq!(Config::parse("").unwrap(), Config::default());
}
#[test]
fn repos_are_normalized_and_deduped() {
let c = Config::parse(
r#"repos = ["https://github.com/storytold/photocraft", "storytold/photocraft.git",
"github.com/a/b/pulls", "nonsense", "x/y z"]"#,
)
.unwrap();
assert_eq!(c.repos, vec!["storytold/photocraft", "a/b"]);
}
#[test]
fn intervals_are_clamped() {
let c = Config::parse("poll_minutes = 0.1\nstale_minutes = 0.5").unwrap();
assert_eq!(c.poll_minutes, 1.0);
assert_eq!(
c.stale_minutes, 1.0,
"never stale before the next poll is due"
);
assert_eq!(c.poll_secs(), 60);
}
#[test]
fn window_state_visibility() {
let w = WindowState {
x: 100,
y: 100,
width: 800,
height: 500,
};
assert!(w.visible_on(&[(0, 0, 1920, 1080)]));
assert!(!w.visible_on(&[(2000, 0, 1920, 1080)]));
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Everything Craft Status knows that isn't network or UI: the per-repo
//! statistics model, release/download accounting, the urgency heuristic for
//! open issues, the config file, the on-disk cache and window geometry.
pub mod cache;
pub mod config;
pub mod model;
pub mod releases;
pub mod urgency;
pub use cache::Cache;
pub use config::{Config, WindowState};
pub use model::{RepoEntry, RepoStats, Snapshot, UrgentIssue};
pub use releases::{summarize_releases, Asset, Release, ReleaseSummary};
pub use urgency::{rank_issues, score_issue, IssueInput, Scored};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("no home directory")]
NoHome,
#[error("{path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("config: {0}")]
Toml(#[from] toml::de::Error),
#[error("cache: {0}")]
Json(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
/// Write via a temp file + rename so readers never see a half-written file.
pub fn atomic_write(path: &std::path::Path, text: &str) -> Result<()> {
let io = |source| Error::Io {
path: path.display().to_string(),
source,
};
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(io)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, text).map_err(io)?;
std::fs::rename(&tmp, path).map_err(io)
}
+188
View File
@@ -0,0 +1,188 @@
//! The data the UI renders. Everything here is serialized to the frontend
//! and to the cache file, so field names are part of both contracts.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub type Time = DateTime<Utc>;
/// One poll's worth of numbers for a repository.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct RepoStats {
pub open_prs: u64,
pub open_issues: u64,
/// Committer date of the head of the default branch (`main`).
pub last_commit_at: Option<Time>,
/// Newest issue, any state.
pub last_issue_at: Option<Time>,
/// Oldest PR that is still open.
pub oldest_open_pr_at: Option<Time>,
/// Newest PR, any state.
pub newest_pr_at: Option<Time>,
pub commits_4h: u64,
pub prs_4h: u64,
pub issues_4h: u64,
pub commits_total: u64,
pub contributors: u64,
pub issues_total: u64,
pub prs_total: u64,
/// Newest published (non-draft) release.
pub latest_build: Option<String>,
pub latest_build_at: Option<Time>,
pub latest_build_prerelease: bool,
pub latest_build_downloads: u64,
pub downloads_total: u64,
/// Sum of the top five urgent-issue scores; the repo-level sort key.
pub urgency: f64,
/// Open issues whose title or labels name a crash, hang, data loss…
pub critical_issues: u64,
/// Highest-scoring open issues, best first.
pub urgent: Vec<UrgentIssue>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct UrgentIssue {
pub number: u64,
pub title: String,
pub url: String,
pub created_at: Option<Time>,
pub updated_at: Option<Time>,
pub comments: u64,
pub reactions: u64,
pub labels: Vec<String>,
pub score: f64,
pub critical: bool,
/// Short human explanations of the score ("crash", "12 reactions"…).
pub reasons: Vec<String>,
}
/// A configured repository and whatever we last managed to learn about it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct RepoEntry {
/// `owner/name`
pub repo: String,
pub stats: Option<RepoStats>,
/// When `stats` was fetched. Kept across failed polls so stale data stays visible.
pub fetched_at: Option<Time>,
pub last_attempt_at: Option<Time>,
/// Error from the most recent attempt, cleared on success.
pub error: Option<String>,
}
impl RepoEntry {
pub fn new(repo: &str) -> Self {
RepoEntry {
repo: repo.to_string(),
..Default::default()
}
}
pub fn name(&self) -> &str {
self.repo.rsplit('/').next().unwrap_or(&self.repo)
}
pub fn is_stale(&self, now: Time, stale_after_secs: i64) -> bool {
match self.fetched_at {
Some(t) => (now - t).num_seconds() > stale_after_secs,
None => true,
}
}
}
/// What the UI receives.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Snapshot {
pub repos: Vec<RepoEntry>,
/// End of the last poll cycle that refreshed every repository.
pub last_full_refresh_at: Option<Time>,
pub refreshing: bool,
pub next_refresh_at: Option<Time>,
pub poll_secs: i64,
pub stale_after_secs: i64,
pub rate_limit_remaining: Option<u64>,
/// Problems that aren't about one repository (no token, rate limited…).
pub error: Option<String>,
}
impl Snapshot {
/// The time of the oldest data on screen; `None` if some repo has none.
pub fn oldest_data_at(&self) -> Option<Time> {
self.repos
.iter()
.map(|r| r.fetched_at)
.try_fold(None::<Time>, |acc, t| {
let t = t?;
Some(Some(acc.map_or(t, |a| a.min(t))))
})
.flatten()
}
pub fn is_stale(&self, now: Time) -> bool {
self.repos.is_empty()
|| self
.repos
.iter()
.any(|r| r.is_stale(now, self.stale_after_secs))
}
/// Make `repos` match the configured list, keeping data for repos that remain.
pub fn sync_repos(&mut self, wanted: &[String]) {
let mut old = std::mem::take(&mut self.repos);
self.repos = wanted
.iter()
.map(|w| {
old.iter()
.position(|e| e.repo.eq_ignore_ascii_case(w))
.map(|i| old.swap_remove(i))
.unwrap_or_else(|| RepoEntry::new(w))
})
.collect();
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn at(mins_ago: i64, now: Time) -> Option<Time> {
Some(now - Duration::minutes(mins_ago))
}
#[test]
fn staleness_uses_the_oldest_repo() {
let now = Utc::now();
let mut s = Snapshot {
stale_after_secs: 15 * 60,
..Default::default()
};
assert!(s.is_stale(now), "nothing configured counts as stale");
s.sync_repos(&["a/x".into(), "a/y".into()]);
assert!(s.is_stale(now), "never fetched");
s.repos[0].fetched_at = at(2, now);
s.repos[1].fetched_at = at(5, now);
assert!(!s.is_stale(now));
assert_eq!(s.oldest_data_at(), at(5, now));
s.repos[1].fetched_at = at(16, now);
assert!(s.is_stale(now));
assert!(!s.repos[0].is_stale(now, s.stale_after_secs));
s.repos[1].fetched_at = None;
assert_eq!(s.oldest_data_at(), None);
}
#[test]
fn sync_repos_keeps_data_and_order() {
let mut s = Snapshot::default();
s.sync_repos(&["o/a".into(), "o/b".into()]);
s.repos[1].error = Some("kept".into());
s.sync_repos(&["o/c".into(), "O/B".into()]);
assert_eq!(s.repos.len(), 2);
assert_eq!(s.repos[0].repo, "o/c");
assert_eq!(s.repos[1].error.as_deref(), Some("kept"));
assert_eq!(s.repos[1].name(), "b");
}
}
+147
View File
@@ -0,0 +1,147 @@
//! "Builds" are GitHub releases. Downloads are summed over the assets people
//! actually install (installers, archives, packages), not over checksum lists,
//! signatures, AppImage delta files or updater manifests, which are fetched by
//! machines and would inflate the numbers.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Asset {
pub name: String,
pub download_count: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Release {
pub tag: String,
pub draft: bool,
pub prerelease: bool,
pub published_at: Option<DateTime<Utc>>,
pub created_at: Option<DateTime<Utc>>,
pub assets: Vec<Asset>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ReleaseSummary {
pub latest_tag: Option<String>,
pub latest_at: Option<DateTime<Utc>>,
pub latest_prerelease: bool,
pub latest_downloads: u64,
pub total_downloads: u64,
}
/// Assets that are not something a person downloads to run the app.
pub fn is_counted_asset(name: &str) -> bool {
let n = name.to_ascii_lowercase();
const SKIP_SUFFIXES: &[&str] = &[
".zsync",
".sha256",
".sha512",
".sha256sum",
".sig",
".asc",
".minisig",
".blockmap",
".json",
".yml",
".yaml",
".txt",
".intoto.jsonl",
];
!(n.starts_with("sha256sums")
|| n.starts_with("checksums")
|| SKIP_SUFFIXES.iter().any(|s| n.ends_with(s)))
}
fn downloads(r: &Release) -> u64 {
r.assets
.iter()
.filter(|a| is_counted_asset(&a.name))
.map(|a| a.download_count)
.sum()
}
/// Latest = newest published, non-draft release (prereleases count: they are
/// builds too). Drafts are invisible to users and are ignored entirely.
pub fn summarize_releases(releases: &[Release]) -> ReleaseSummary {
let published: Vec<&Release> = releases.iter().filter(|r| !r.draft).collect();
let latest = published
.iter()
.max_by_key(|r| r.published_at.or(r.created_at));
ReleaseSummary {
latest_tag: latest.map(|r| r.tag.clone()),
latest_at: latest.and_then(|r| r.published_at.or(r.created_at)),
latest_prerelease: latest.is_some_and(|r| r.prerelease),
latest_downloads: latest.map_or(0, |r| downloads(r)),
total_downloads: published.iter().map(|r| downloads(r)).sum(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rel(tag: &str, draft: bool, day: u32, assets: &[(&str, u64)]) -> Release {
Release {
tag: tag.into(),
draft,
prerelease: tag.contains("rc"),
published_at: (!draft).then(|| format!("2026-10-{day:02}T00:00:00Z").parse().unwrap()),
created_at: Some(format!("2026-10-{day:02}T00:00:00Z").parse().unwrap()),
assets: assets
.iter()
.map(|(n, c)| Asset {
name: n.to_string(),
download_count: *c,
})
.collect(),
}
}
#[test]
fn filters_machine_assets() {
assert!(is_counted_asset("photocraft-0.5.0-macos-universal.dmg"));
assert!(is_counted_asset("photocraft-web-0.5.0.zip"));
assert!(is_counted_asset("x-linux-x86_64.AppImage"));
assert!(!is_counted_asset("x-linux-x86_64.AppImage.zsync"));
assert!(!is_counted_asset("SHA256SUMS.txt"));
assert!(!is_counted_asset("latest.json"));
assert!(!is_counted_asset("x.dmg.sig"));
}
#[test]
fn latest_skips_drafts_and_totals_published() {
let rs = vec![
rel("v0.4.1", true, 8, &[("a.dmg", 0)]),
rel(
"v0.5.0",
false,
7,
&[("a.dmg", 10), ("a.msi", 5), ("SHA256SUMS.txt", 99)],
),
rel(
"v0.3.0",
false,
3,
&[("a.dmg", 100), ("a.AppImage.zsync", 50)],
),
];
let s = summarize_releases(&rs);
assert_eq!(s.latest_tag.as_deref(), Some("v0.5.0"));
assert_eq!(s.latest_downloads, 15);
assert_eq!(s.total_downloads, 115);
assert!(!s.latest_prerelease);
}
#[test]
fn prerelease_can_be_latest_and_empty_is_empty() {
let s = summarize_releases(&[
rel("v0.1.0", false, 1, &[]),
rel("v0.2.0-rc.1", false, 2, &[]),
]);
assert_eq!(s.latest_tag.as_deref(), Some("v0.2.0-rc.1"));
assert!(s.latest_prerelease);
assert_eq!(summarize_releases(&[]), ReleaseSummary::default());
}
}
+440
View File
@@ -0,0 +1,440 @@
//! A transparent, deterministic heuristic for "what should be fixed first".
//!
//! Every open issue gets points for what it says (crash / hang / data loss
//! beats "doesn't work" beats everything else), for who filed it (a user
//! report outranks the team's own automated findings), for how many people
//! are piling on (reactions, comments), for naming the latest build, and for
//! being fresh. Feature requests, questions and docs nits are pushed down.
//! Each contribution is recorded as a short reason so the UI can show *why*.
use chrono::{DateTime, Duration, Utc};
use crate::model::UrgentIssue;
#[derive(Debug, Clone, Default)]
pub struct IssueInput {
pub number: u64,
pub title: String,
pub url: String,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
pub comments: u64,
pub reactions: u64,
pub labels: Vec<String>,
/// GitHub's `authorAssociation` (`NONE`, `CONTRIBUTOR`, `MEMBER`…).
pub author_association: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Scored {
pub score: f64,
pub critical: bool,
pub reasons: Vec<String>,
}
/// Phrases that mean the app is unusable or user data is at risk.
const CRITICAL: &[(&str, &str)] = &[
("crash", "crash"),
("crashes", "crash"),
("crashed", "crash"),
("crashing", "crash"),
("panic", "panic"),
("panics", "panic"),
("segfault", "crash"),
("data loss", "data loss"),
("lost my", "data loss"),
("lost work", "data loss"),
("corrupt", "corruption"),
("corrupted", "corruption"),
("corrupts", "corruption"),
("freeze", "freeze"),
("freezes", "freeze"),
("frozen", "freeze"),
("hang", "hang"),
("hangs", "hang"),
("unresponsive", "hang"),
("not responding", "hang"),
("won't open", "won't open"),
("wont open", "won't open"),
("can't open", "won't open"),
("cannot open", "won't open"),
("won't launch", "won't launch"),
("wont launch", "won't launch"),
("won't start", "won't launch"),
("doesn't start", "won't launch"),
("does not start", "won't launch"),
("fails to launch", "won't launch"),
("fails to start", "won't launch"),
("not launching", "won't launch"),
("black screen", "blank screen"),
("blank screen", "blank screen"),
("white screen", "blank screen"),
("security", "security"),
("vulnerability", "security"),
("malware", "security"),
("virus", "security"),
("trojan", "security"),
];
/// Phrases that mean something is broken, but not catastrophically.
const BROKEN: &[&str] = &[
"bug",
"broken",
"regression",
"doesn't work",
"does not work",
"not working",
"doesn't",
"does not",
"don't",
"fails",
"failed",
"failure",
"error",
"can't",
"cannot",
"unable",
"wrong",
"lost",
"install",
"installer",
"slow",
"lag",
"glitch",
];
const FEATURE_LABELS: &[&str] = &[
"enhancement",
"feature",
"feature request",
"question",
"documentation",
"docs",
];
const EXCLUDE_LABELS: &[&str] = &["wontfix", "duplicate", "invalid", "not planned"];
const FEATURE_PREFIXES: &[&str] = &[
"[feature",
"feature request",
"feature:",
"request:",
"[request",
"suggestion",
"[suggestion",
"idea:",
"add ",
"support ",
"please add",
"fix(docs)",
"docs:",
"docs(",
"[docs",
"question",
"[question",
];
/// Lowercase, normalize apostrophes, and turn punctuation into spaces, padded
/// so `" phrase "` matches whole words only.
fn normalize(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push(' ');
let mut last_space = true;
for c in s.chars().flat_map(char::to_lowercase) {
let c = if c == '\u{2019}' || c == '`' { '\'' } else { c };
if c.is_alphanumeric() || c == '\'' {
out.push(c);
last_space = false;
} else if !last_space {
out.push(' ');
last_space = true;
}
}
if !last_space {
out.push(' ');
}
out
}
fn has_phrase(text: &str, phrase: &str) -> bool {
text.contains(&format!(" {phrase} "))
}
/// `"v0.5.0"` → `"0.5"`: issues name the version loosely ("0.5", "0.50", "v0.5.0").
fn major_minor(tag: &str) -> Option<String> {
let t = tag.trim_start_matches(['v', 'V']);
let mut parts = t.split(['.', '-']);
let major = parts
.next()
.filter(|p| p.chars().all(|c| c.is_ascii_digit()))?;
let minor = parts
.next()
.filter(|p| p.chars().all(|c| c.is_ascii_digit()))?;
(!major.is_empty() && !minor.is_empty()).then(|| format!("{major}.{minor}"))
}
fn mentions_version(title: &str, mm: &str) -> bool {
let b = title.as_bytes();
title.match_indices(mm).any(|(i, _)| {
let before_ok = i == 0 || !b[i - 1].is_ascii_digit();
let after = b.get(i + mm.len()).copied();
// "0.5" matches "0.5", "0.5.0", "0.50" (a common typo for 0.5.0) but not "0.51".
let after_ok = match after {
None => true,
Some(c) if !c.is_ascii_digit() => true,
Some(b'0') => !b.get(i + mm.len() + 1).is_some_and(|c| c.is_ascii_digit()),
_ => false,
};
before_ok && after_ok
})
}
/// `None` when the issue is closed-in-spirit (wontfix, duplicate, invalid).
pub fn score_issue(
issue: &IssueInput,
now: DateTime<Utc>,
latest_tag: Option<&str>,
) -> Option<Scored> {
let labels: Vec<String> = issue.labels.iter().map(|l| l.to_lowercase()).collect();
if labels.iter().any(|l| EXCLUDE_LABELS.contains(&l.as_str())) {
return None;
}
let title_lc = issue.title.to_lowercase();
let text = normalize(&format!("{} {}", issue.title, issue.labels.join(" ")));
let mut score = 0.0;
let mut reasons: Vec<String> = Vec::new();
let feature_like = labels.iter().any(|l| FEATURE_LABELS.contains(&l.as_str()))
|| FEATURE_PREFIXES
.iter()
.any(|p| title_lc.trim_start().starts_with(p));
let mut critical_hits: Vec<&str> = Vec::new();
for (phrase, why) in CRITICAL {
if has_phrase(&text, phrase) && !critical_hits.contains(why) {
critical_hits.push(why);
}
}
let critical = !critical_hits.is_empty() && !feature_like;
if !critical_hits.is_empty() {
score += 40.0;
reasons.push(critical_hits.join(", "));
} else if BROKEN.iter().any(|p| has_phrase(&text, p)) {
score += 15.0;
reasons.push("broken".into());
}
if labels.iter().any(|l| l == "bug" || l == "regression") {
score += 15.0;
reasons.push("bug label".into());
}
if labels
.iter()
.any(|l| l == "reliability" || l == "file-compat" || l == "performance")
{
score += 6.0;
}
if feature_like {
score -= 25.0;
reasons.push("request/docs".into());
}
match issue.author_association.as_str() {
"NONE" | "FIRST_TIMER" | "FIRST_TIME_CONTRIBUTOR" => {
score += 10.0;
reasons.push("user report".into());
}
_ => {}
}
let engagement = 3.0 * issue.reactions as f64 + 2.0 * issue.comments as f64;
if engagement > 0.0 {
score += (10.0 * (1.0 + engagement).ln()).min(40.0);
if issue.reactions > 0 {
reasons.push(format!("{} 👍", issue.reactions));
}
if issue.comments > 0 {
reasons.push(format!("{} 💬", issue.comments));
}
}
if let Some(mm) = latest_tag.and_then(major_minor) {
if mentions_version(&title_lc, &mm) {
score += 10.0;
reasons.push("latest build".into());
}
}
if let Some(created) = issue.created_at {
let age = now - created;
if age < Duration::hours(24) {
score += 8.0;
reasons.push("new".into());
} else if age < Duration::hours(72) {
score += 4.0;
}
}
if let Some(updated) = issue.updated_at {
if now - updated > Duration::days(30) {
score -= 5.0;
}
}
Some(Scored {
score: score.max(0.0),
critical,
reasons,
})
}
/// Returns the top `keep` issues (score > 0, best first, ties → newest), the
/// repo urgency (sum of the top five scores) and the count of critical issues.
pub fn rank_issues(
issues: &[IssueInput],
now: DateTime<Utc>,
latest_tag: Option<&str>,
keep: usize,
) -> (Vec<UrgentIssue>, f64, u64) {
let mut seen = std::collections::HashSet::new();
let mut ranked: Vec<UrgentIssue> = issues
.iter()
.filter(|i| seen.insert(i.number))
.filter_map(|i| {
let s = score_issue(i, now, latest_tag)?;
Some(UrgentIssue {
number: i.number,
title: i.title.clone(),
url: i.url.clone(),
created_at: i.created_at,
updated_at: i.updated_at,
comments: i.comments,
reactions: i.reactions,
labels: i.labels.clone(),
score: s.score.round(),
critical: s.critical,
reasons: s.reasons,
})
})
.collect();
let critical = ranked.iter().filter(|u| u.critical).count() as u64;
ranked.retain(|u| u.score > 0.0);
ranked.sort_by(|a, b| {
b.score
.total_cmp(&a.score)
.then(b.created_at.cmp(&a.created_at))
});
let urgency = ranked.iter().take(5).map(|u| u.score).sum();
ranked.truncate(keep);
(ranked, urgency, critical)
}
#[cfg(test)]
mod tests {
use super::*;
fn issue(title: &str, assoc: &str) -> IssueInput {
IssueInput {
number: title.len() as u64,
title: title.into(),
author_association: assoc.into(),
..Default::default()
}
}
fn score(i: &IssueInput) -> f64 {
score_issue(i, Utc::now(), Some("v0.5.0")).unwrap().score
}
#[test]
fn normalize_is_word_bounded() {
let t = normalize("App CRASHES—on start’s (Windows)");
assert!(has_phrase(&t, "crashes"));
assert!(has_phrase(&t, "start's"));
assert!(!has_phrase(&normalize("hangar"), "hang"));
}
#[test]
fn crash_report_beats_automated_docs_fix() {
let crash = issue(
"[Windows 11 0.50] Merge Down freezes on large canvas",
"NONE",
);
let docs = issue(
"fix(docs): layer-type errors say \"a Adjustment layer\"",
"CONTRIBUTOR",
);
let s = score_issue(&crash, Utc::now(), Some("v0.5.0")).unwrap();
assert!(s.critical);
assert!(
s.reasons.iter().any(|r| r == "latest build"),
"{:?}",
s.reasons
);
assert!(s.reasons.iter().any(|r| r == "user report"));
assert!(score(&crash) > score(&docs) + 40.0);
assert!(!score_issue(&docs, Utc::now(), None).unwrap().critical);
}
#[test]
fn feature_requests_sink_and_wontfix_vanishes() {
let bug = issue("delete button doesn't delete a layer on windows", "NONE");
let feat = issue("Request: Gradient support for shape fill", "NONE");
assert!(score(&bug) > score(&feat));
let mut dup = issue("App crashes", "NONE");
dup.labels = vec!["duplicate".into()];
assert!(score_issue(&dup, Utc::now(), None).is_none());
let feat_crash = issue("Add crash reporting", "NONE");
assert!(!score_issue(&feat_crash, Utc::now(), None).unwrap().critical);
}
#[test]
fn engagement_is_capped_and_counted() {
let quiet = issue("Bug: layer locked but editing still works", "NONE");
let mut loud = quiet.clone();
loud.reactions = 15;
loud.comments = 13;
let mut louder = quiet.clone();
louder.reactions = 5000;
assert!(score(&loud) > score(&quiet) + 30.0);
assert!(score(&louder) - score(&quiet) <= 40.0 + 1e-9);
}
#[test]
fn version_matching() {
assert_eq!(major_minor("v0.5.0").as_deref(), Some("0.5"));
assert_eq!(major_minor("v0.1.0-rc.3").as_deref(), Some("0.1"));
assert_eq!(major_minor("nightly"), None);
assert!(mentions_version("[windows 0.5.0] x", "0.5"));
assert!(mentions_version("[windows 11 0.50] x", "0.5"));
assert!(!mentions_version("v10.5 x", "0.5"));
assert!(!mentions_version("0.51 x", "0.5"));
}
#[test]
fn ranking_dedupes_sorts_and_sums_top_five() {
let now = Utc::now();
let mut v: Vec<IssueInput> = (1..=8)
.map(|n| IssueInput {
number: n,
title: if n % 2 == 0 {
"App crashes"
} else {
"Add gradients"
}
.into(),
author_association: "NONE".into(),
..Default::default()
})
.collect();
v.push(v[1].clone());
let (ranked, urgency, critical) = rank_issues(&v, now, None, 3);
assert_eq!(critical, 4);
assert_eq!(ranked.len(), 3);
assert!(ranked.iter().all(|u| u.critical));
assert!(ranked.windows(2).all(|w| w[0].score >= w[1].score));
assert_eq!(
urgency,
4.0 * 50.0,
"4 crashes at 50 (40 + user 10), features score 0"
);
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "craft-github"
description = "GitHub GraphQL/REST fetcher that fills craft-core's RepoStats"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
craft-core = { path = "../craft-core" }
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
reqwest.workspace = true
thiserror.workspace = true
@@ -0,0 +1,77 @@
//! `craft-fetch [owner/name …] [--snapshot PATH]` — fetch stats once and print
//! a table (debugging aid). `--snapshot` also writes a Snapshot JSON the UI
//! can load in a plain browser (`ui/dev-snapshot.json`).
use chrono::Utc;
use craft_core::{Config, RepoEntry, Snapshot};
use craft_github::{resolve_token, GitHub};
fn main() {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let snapshot_path = args.iter().position(|a| a == "--snapshot").map(|i| {
args.remove(i);
args.remove(i)
});
let cfg = Config::load_from(&Config::path().unwrap()).unwrap_or_default();
let repos = if args.is_empty() {
cfg.repos.clone()
} else {
args
};
let gh = match resolve_token(&cfg.github_token).and_then(GitHub::new) {
Ok(gh) => gh,
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
};
let now = Utc::now();
let mut snap = Snapshot {
poll_secs: cfg.poll_secs(),
stale_after_secs: cfg.stale_secs(),
..Default::default()
};
println!(
"{:<12} {:>5} {:>6} {:>4} {:>4} {:>4} {:>7} {:>5} {:>7} {:>7} {:<12} {:>7} {:>8} {:>6} {:>4}",
"repo", "oPR", "oIss", "c4h", "p4h", "i4h", "commits", "ctrb", "issues", "prs", "build", "bld dl",
"total dl", "urg", "crit"
);
for repo in &repos {
let mut entry = RepoEntry::new(repo);
entry.last_attempt_at = Some(now);
match gh.fetch_repo(repo, now) {
Ok((s, budget)) => {
println!(
"{:<12} {:>5} {:>6} {:>4} {:>4} {:>4} {:>7} {:>5} {:>7} {:>7} {:<12} {:>7} {:>8} {:>6} {:>4}",
entry.name(), s.open_prs, s.open_issues, s.commits_4h, s.prs_4h, s.issues_4h,
s.commits_total, s.contributors, s.issues_total, s.prs_total,
s.latest_build.clone().unwrap_or_default(), s.latest_build_downloads,
s.downloads_total, s.urgency, s.critical_issues
);
for u in s.urgent.iter().take(3) {
println!(
" {:>4} #{:<5} {} [{}]",
u.score,
u.number,
u.title,
u.reasons.join(", ")
);
}
snap.rate_limit_remaining = budget.graphql_remaining;
entry.stats = Some(s);
entry.fetched_at = Some(now);
}
Err(e) => {
println!("{:<12} ERROR {e}", entry.name());
entry.error = Some(e.to_string());
}
}
snap.repos.push(entry);
}
snap.last_full_refresh_at = Some(Utc::now());
eprintln!("graphql budget remaining: {:?}", snap.rate_limit_remaining);
if let Some(p) = snapshot_path {
std::fs::write(&p, serde_json::to_string_pretty(&snap).unwrap()).unwrap();
eprintln!("wrote {p}");
}
}
+474
View File
@@ -0,0 +1,474 @@
//! Fetches one repository's [`RepoStats`] from GitHub.
//!
//! Per repo: one GraphQL query for counts, dates, the 4-hour windows and the
//! open issues to rank; one (usually) GraphQL query for releases + asset
//! download counts; one REST call for the contributor count (GraphQL has no
//! such field — we ask for one per page and read the last page number from
//! the `Link` header). Blocking I/O: callers run this on worker threads.
use std::process::Command;
use std::time::Duration;
use chrono::{DateTime, Utc};
use craft_core::{rank_issues, summarize_releases, Asset, IssueInput, Release, RepoStats};
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, USER_AGENT};
use serde_json::{json, Value};
const API: &str = "https://api.github.com";
/// The "last N hours" window the UI reports.
pub const RECENT_HOURS: i64 = 4;
/// How many ranked issues each repo keeps for the Urgent view.
const KEEP_URGENT: usize = 20;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("no GitHub token: set github_token in ~/.craft_status_config.toml, export GITHUB_TOKEN, or run `gh auth login`")]
NoToken,
#[error("GitHub rejected the token (401); check github_token / `gh auth status`")]
Unauthorized,
#[error("rate limited by GitHub until {0}")]
RateLimited(String),
#[error("GitHub HTTP {0}: {1}")]
Http(u16, String),
#[error("network: {0}")]
Network(#[from] reqwest::Error),
#[error("GitHub: {0}")]
GraphQl(String),
}
pub type Result<T> = std::result::Result<T, Error>;
/// Token from config, then `GITHUB_TOKEN` / `GH_TOKEN`, then the GitHub CLI.
/// Apps launched from Finder don't inherit the shell PATH, so the usual
/// install locations of `gh` are tried explicitly.
pub fn resolve_token(configured: &str) -> Result<String> {
let t = configured.trim();
if !t.is_empty() {
return Ok(t.to_string());
}
for var in ["GITHUB_TOKEN", "GH_TOKEN"] {
if let Ok(v) = std::env::var(var) {
if !v.trim().is_empty() {
return Ok(v.trim().to_string());
}
}
}
for gh in [
"gh",
"/opt/homebrew/bin/gh",
"/usr/local/bin/gh",
"/usr/bin/gh",
] {
if let Ok(out) = Command::new(gh).args(["auth", "token"]).output() {
let tok = String::from_utf8_lossy(&out.stdout).trim().to_string();
if out.status.success() && !tok.is_empty() {
return Ok(tok);
}
}
}
Err(Error::NoToken)
}
pub struct GitHub {
http: Client,
token: String,
}
/// Rate-limit bookkeeping returned alongside each repo.
#[derive(Debug, Clone, Copy, Default)]
pub struct Budget {
pub graphql_remaining: Option<u64>,
}
impl GitHub {
pub fn new(token: String) -> Result<GitHub> {
let http = Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.build()?;
Ok(GitHub { http, token })
}
fn headers(&self) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(USER_AGENT, "craft-status".parse().unwrap());
h.insert(ACCEPT, "application/vnd.github+json".parse().unwrap());
if let Ok(v) = format!("Bearer {}", self.token).parse() {
h.insert(AUTHORIZATION, v);
}
h
}
fn check(resp: reqwest::blocking::Response) -> Result<reqwest::blocking::Response> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
if status.as_u16() == 401 {
return Err(Error::Unauthorized);
}
let remaining = resp
.headers()
.get("x-ratelimit-remaining")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let reset = resp
.headers()
.get("x-ratelimit-reset")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<i64>().ok())
.and_then(|t| DateTime::from_timestamp(t, 0));
let body = resp.text().unwrap_or_default();
if (status.as_u16() == 403 || status.as_u16() == 429)
&& (remaining.as_deref() == Some("0") || body.contains("rate limit"))
{
return Err(Error::RateLimited(
reset.map_or("later".into(), |t| t.to_rfc3339()),
));
}
let msg = serde_json::from_str::<Value>(&body)
.ok()
.and_then(|v| v["message"].as_str().map(str::to_string))
.unwrap_or_else(|| body.chars().take(200).collect());
Err(Error::Http(status.as_u16(), msg))
}
fn graphql(&self, query: &str, variables: Value) -> Result<Value> {
let resp = self
.http
.post(format!("{API}/graphql"))
.headers(self.headers())
.json(&json!({ "query": query, "variables": variables }))
.send()?;
let v: Value = Self::check(resp)?.json()?;
if let Some(errs) = v["errors"].as_array().filter(|e| !e.is_empty()) {
let msg = errs
.iter()
.filter_map(|e| e["message"].as_str())
.collect::<Vec<_>>()
.join("; ");
if errs.iter().any(|e| e["type"] == "RATE_LIMITED") {
return Err(Error::RateLimited("the top of the hour".into()));
}
if v["data"]["repository"].is_null() {
return Err(Error::GraphQl(msg));
}
}
Ok(v["data"].clone())
}
/// Everything the UI shows for `owner/name`, measured at `now`.
pub fn fetch_repo(&self, repo: &str, now: DateTime<Utc>) -> Result<(RepoStats, Budget)> {
let (owner, name) = repo
.split_once('/')
.ok_or_else(|| Error::GraphQl(format!("bad repo {repo:?}")))?;
let since = now - chrono::Duration::hours(RECENT_HOURS);
let since_s = since.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let main = self.graphql(
MAIN_QUERY,
json!({
"owner": owner,
"name": name,
"since": since_s,
"prs4h": format!("repo:{repo} is:pr created:>={since_s}"),
"issues4h": format!("repo:{repo} is:issue created:>={since_s}"),
}),
)?;
let releases = self.releases(owner, name)?;
let contributors = self.contributors(repo)?;
let budget = Budget {
graphql_remaining: main["rateLimit"]["remaining"].as_u64(),
};
Ok((build_stats(&main, &releases, contributors, now), budget))
}
fn releases(&self, owner: &str, name: &str) -> Result<Vec<Release>> {
let mut out = Vec::new();
let mut after: Option<String> = None;
// 20 pages × 50 = 1000 releases is plenty; the cap guards against loops.
for _ in 0..20 {
let v = self.graphql(
RELEASES_QUERY,
json!({ "owner": owner, "name": name, "after": after }),
)?;
let conn = &v["repository"]["releases"];
out.extend(parse_releases(conn));
if conn["pageInfo"]["hasNextPage"].as_bool() != Some(true) {
break;
}
after = conn["pageInfo"]["endCursor"].as_str().map(str::to_string);
}
Ok(out)
}
fn contributors(&self, repo: &str) -> Result<u64> {
let resp = self
.http
.get(format!("{API}/repos/{repo}/contributors?per_page=1"))
.headers(self.headers())
.send()?;
let resp = Self::check(resp)?;
// 204: empty repository.
if resp.status().as_u16() == 204 {
return Ok(0);
}
let link = resp
.headers()
.get("link")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
if let Some(n) = link.as_deref().and_then(last_page) {
return Ok(n);
}
let body: Value = resp.json()?;
Ok(body.as_array().map_or(0, |a| a.len() as u64))
}
}
/// `<…&page=37>; rel="last"` → 37.
pub fn last_page(link: &str) -> Option<u64> {
link.split(',').find_map(|part| {
let (url, rel) = part.split_once(';')?;
if !rel.contains("rel=\"last\"") {
return None;
}
let url = url.trim().trim_start_matches('<').trim_end_matches('>');
let query = url.split_once('?')?.1;
query
.split('&')
.find_map(|kv| kv.strip_prefix("page="))
.and_then(|n| n.parse().ok())
})
}
fn time(v: &Value) -> Option<DateTime<Utc>> {
v.as_str()?.parse().ok()
}
fn count(v: &Value) -> u64 {
v["totalCount"].as_u64().unwrap_or(0)
}
fn first_time(conn: &Value, field: &str) -> Option<DateTime<Utc>> {
time(&conn["nodes"][0][field])
}
pub fn parse_releases(conn: &Value) -> Vec<Release> {
conn["nodes"]
.as_array()
.map(|nodes| {
nodes
.iter()
.map(|r| Release {
tag: r["tagName"].as_str().unwrap_or_default().to_string(),
draft: r["isDraft"].as_bool().unwrap_or(false),
prerelease: r["isPrerelease"].as_bool().unwrap_or(false),
published_at: time(&r["publishedAt"]),
created_at: time(&r["createdAt"]),
assets: r["releaseAssets"]["nodes"]
.as_array()
.map(|a| {
a.iter()
.map(|a| Asset {
name: a["name"].as_str().unwrap_or_default().to_string(),
download_count: a["downloadCount"].as_u64().unwrap_or(0),
})
.collect()
})
.unwrap_or_default(),
})
.collect()
})
.unwrap_or_default()
}
fn parse_issues(conn: &Value) -> Vec<IssueInput> {
conn["nodes"]
.as_array()
.map(|nodes| {
nodes
.iter()
.map(|i| IssueInput {
number: i["number"].as_u64().unwrap_or(0),
title: i["title"].as_str().unwrap_or_default().to_string(),
url: i["url"].as_str().unwrap_or_default().to_string(),
created_at: time(&i["createdAt"]),
updated_at: time(&i["updatedAt"]),
comments: count(&i["comments"]),
reactions: count(&i["reactions"]),
labels: i["labels"]["nodes"]
.as_array()
.map(|l| {
l.iter()
.filter_map(|l| l["name"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
author_association: i["authorAssociation"]
.as_str()
.unwrap_or_default()
.to_string(),
})
.collect()
})
.unwrap_or_default()
}
/// Pure: turn the GraphQL payloads into the model (unit-tested below).
pub fn build_stats(
main: &Value,
releases: &[Release],
contributors: u64,
now: DateTime<Utc>,
) -> RepoStats {
let r = &main["repository"];
let head = &r["defaultBranchRef"]["target"];
let rel = summarize_releases(releases);
let mut issues = parse_issues(&r["recentOpen"]);
issues.extend(parse_issues(&r["hotOpen"]));
let (urgent, urgency, critical) =
rank_issues(&issues, now, rel.latest_tag.as_deref(), KEEP_URGENT);
RepoStats {
open_prs: count(&r["openPrs"]),
open_issues: count(&r["openIssues"]),
last_commit_at: first_time(&head["all"], "committedDate"),
last_issue_at: first_time(&r["newestIssue"], "createdAt"),
oldest_open_pr_at: first_time(&r["oldestOpenPr"], "createdAt"),
newest_pr_at: first_time(&r["newestPr"], "createdAt"),
commits_4h: count(&head["recent"]),
prs_4h: main["prs4h"]["issueCount"].as_u64().unwrap_or(0),
issues_4h: main["issues4h"]["issueCount"].as_u64().unwrap_or(0),
commits_total: count(&head["all"]),
contributors,
issues_total: count(&r["allIssues"]),
prs_total: count(&r["allPrs"]),
latest_build: rel.latest_tag,
latest_build_at: rel.latest_at,
latest_build_prerelease: rel.latest_prerelease,
latest_build_downloads: rel.latest_downloads,
downloads_total: rel.total_downloads,
urgency,
critical_issues: critical,
urgent,
}
}
const MAIN_QUERY: &str = r#"
query($owner: String!, $name: String!, $since: GitTimestamp!, $prs4h: String!, $issues4h: String!) {
repository(owner: $owner, name: $name) {
openPrs: pullRequests(states: OPEN) { totalCount }
allPrs: pullRequests { totalCount }
oldestOpenPr: pullRequests(states: OPEN, first: 1, orderBy: {field: CREATED_AT, direction: ASC}) { nodes { createdAt } }
newestPr: pullRequests(first: 1, orderBy: {field: CREATED_AT, direction: DESC}) { nodes { createdAt } }
openIssues: issues(states: OPEN) { totalCount }
allIssues: issues { totalCount }
newestIssue: issues(first: 1, orderBy: {field: CREATED_AT, direction: DESC}) { nodes { createdAt } }
recentOpen: issues(states: OPEN, first: 100, orderBy: {field: CREATED_AT, direction: DESC}) { nodes { ...I } }
hotOpen: issues(states: OPEN, first: 30, orderBy: {field: COMMENTS, direction: DESC}) { nodes { ...I } }
defaultBranchRef {
target {
... on Commit {
all: history(first: 1) { totalCount nodes { committedDate } }
recent: history(since: $since) { totalCount }
}
}
}
}
prs4h: search(query: $prs4h, type: ISSUE) { issueCount }
issues4h: search(query: $issues4h, type: ISSUE) { issueCount }
rateLimit { remaining resetAt }
}
fragment I on Issue {
number title url createdAt updatedAt authorAssociation
comments { totalCount }
reactions { totalCount }
labels(first: 10) { nodes { name } }
}
"#;
const RELEASES_QUERY: &str = r#"
query($owner: String!, $name: String!, $after: String) {
repository(owner: $owner, name: $name) {
releases(first: 50, after: $after, orderBy: {field: CREATED_AT, direction: DESC}) {
pageInfo { hasNextPage endCursor }
nodes {
tagName isDraft isPrerelease publishedAt createdAt
releaseAssets(first: 100) { nodes { name downloadCount } }
}
}
}
}
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn link_header_last_page() {
let l = r#"<https://api.github.com/repositories/1/contributors?per_page=1&page=2>; rel="next", <https://api.github.com/repositories/1/contributors?per_page=1&page=37>; rel="last""#;
assert_eq!(last_page(l), Some(37));
assert_eq!(last_page(r#"<https://x?page=2>; rel="next""#), None);
}
#[test]
fn builds_stats_from_payloads() {
let main = json!({
"repository": {
"openPrs": {"totalCount": 3}, "allPrs": {"totalCount": 40},
"oldestOpenPr": {"nodes": [{"createdAt": "2026-09-01T00:00:00Z"}]},
"newestPr": {"nodes": [{"createdAt": "2026-10-08T01:00:00Z"}]},
"openIssues": {"totalCount": 7}, "allIssues": {"totalCount": 90},
"newestIssue": {"nodes": [{"createdAt": "2026-10-08T02:00:00Z"}]},
"recentOpen": {"nodes": [
{"number": 5, "title": "App crashes on launch", "url": "u5", "createdAt": "2026-10-08T02:00:00Z",
"updatedAt": "2026-10-08T02:00:00Z", "authorAssociation": "NONE",
"comments": {"totalCount": 2}, "reactions": {"totalCount": 1}, "labels": {"nodes": [{"name": "bug"}]}}
]},
"hotOpen": {"nodes": []},
"defaultBranchRef": {"target": {
"all": {"totalCount": 1234, "nodes": [{"committedDate": "2026-10-08T03:00:00Z"}]},
"recent": {"totalCount": 9}}}
},
"prs4h": {"issueCount": 2}, "issues4h": {"issueCount": 4}
});
let rel = parse_releases(&json!({"nodes": [
{"tagName": "v1.1.0", "isDraft": true, "isPrerelease": false, "publishedAt": null, "createdAt": "2026-10-08T00:00:00Z",
"releaseAssets": {"nodes": [{"name": "a.dmg", "downloadCount": 0}]}},
{"tagName": "v1.0.0", "isDraft": false, "isPrerelease": false, "publishedAt": "2026-10-01T00:00:00Z", "createdAt": "2026-10-01T00:00:00Z",
"releaseAssets": {"nodes": [{"name": "a.dmg", "downloadCount": 10}, {"name": "SHA256SUMS.txt", "downloadCount": 4}]}}
]}));
let now: DateTime<Utc> = "2026-10-08T04:00:00Z".parse().unwrap();
let s = build_stats(&main, &rel, 12, now);
assert_eq!(
(s.open_prs, s.prs_total, s.open_issues, s.issues_total),
(3, 40, 7, 90)
);
assert_eq!(
(s.commits_total, s.commits_4h, s.prs_4h, s.issues_4h),
(1234, 9, 2, 4)
);
assert_eq!(s.contributors, 12);
assert_eq!(s.latest_build.as_deref(), Some("v1.0.0"));
assert_eq!((s.latest_build_downloads, s.downloads_total), (10, 10));
assert_eq!(
s.last_commit_at,
Some("2026-10-08T03:00:00Z".parse().unwrap())
);
assert_eq!(s.critical_issues, 1);
assert_eq!(s.urgent[0].number, 5);
assert!(s.urgency > 0.0);
}
#[test]
fn empty_repository_is_all_zero() {
let s = build_stats(
&json!({"repository": {"defaultBranchRef": null}}),
&[],
0,
Utc::now(),
);
assert_eq!(s, RepoStats::default());
}
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "craft-status-app"
description = "Tray dashboard for the storytold *craft repositories"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "craft_status_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
craft-core = { path = "../craft-core" }
craft-github = { path = "../craft-github" }
tauri = { version = "2", features = ["tray-icon", "image-png", "macos-private-api"] }
tauri-plugin-global-shortcut = "2"
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,15 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capabilities for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-set-always-on-top",
"core:window:allow-start-resize-dragging"
]
}
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1b2a4a"/>
<stop offset="1" stop-color="#0b1220"/>
</linearGradient>
</defs>
<rect x="64" y="64" width="896" height="896" rx="200" fill="url(#bg)"/>
<rect x="220" y="560" width="120" height="200" rx="24" fill="#5b9dff"/>
<rect x="380" y="430" width="120" height="330" rx="24" fill="#7cdbad"/>
<rect x="540" y="330" width="120" height="430" rx="24" fill="#f4bc70"/>
<rect x="700" y="240" width="120" height="520" rx="24" fill="#ff8f8f"/>
<circle cx="300" cy="330" r="70" fill="none" stroke="#dbe4f3" stroke-width="36"/>
<path d="M300 285 v45 l30 22" fill="none" stroke="#dbe4f3" stroke-width="30" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 820 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="6" y="38" width="10" height="20" rx="2" fill="#000"/>
<rect x="20" y="28" width="10" height="30" rx="2" fill="#000"/>
<rect x="34" y="18" width="10" height="40" rx="2" fill="#000"/>
<rect x="48" y="6" width="10" height="52" rx="2" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

+725
View File
@@ -0,0 +1,725 @@
//! Tauri shell around `craft-core` + `craft-github`: the poll loop, the
//! on-disk cache, tray icon, global shortcut and window management.
//!
//! One background thread owns polling. Every `poll_minutes` (and on demand)
//! it fetches all repositories with a few worker threads, publishing the
//! snapshot to the UI after each repository lands and to the cache at the end.
//! Failed repositories keep their previous numbers; staleness is derived from
//! each repository's `fetched_at`, so old data is always visibly old.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Mutex};
use std::time::{Duration, SystemTime};
use chrono::{DateTime, Local, Utc};
use craft_core::{Cache, Config, Snapshot, WindowState};
use craft_github::{resolve_token, Error as GhError, GitHub};
use serde::Serialize;
use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Emitter, LogicalSize, Manager, State, WebviewWindow, Wry};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
const MAIN: &str = "main";
const TRAY: &str = "main";
const EV_SNAPSHOT: &str = "craft:snapshot";
const EV_FOCUS: &str = "craft:focus";
const EV_META: &str = "craft:meta";
/// Repositories fetched concurrently. Small on purpose: GitHub frowns on
/// bursts (secondary rate limits) and 12 repos finish in well under 15 s.
const WORKERS: usize = 4;
/// How often the poll thread wakes to check the clock, the config file and
/// the tray tooltip. Wall-clock based, so a sleeping laptop polls on wake.
const TICK: Duration = Duration::from_secs(15);
/// Retry sooner than the poll interval when everything failed (offline, asleep…).
const RETRY_AFTER_FAILURE: i64 = 60;
/// Ignore manual refreshes this soon after the previous poll finished.
const MANUAL_DEBOUNCE: i64 = 15;
/// Below this many GraphQL points left, wait for the hourly reset.
const BUDGET_FLOOR: u64 = 150;
pub struct AppState {
config: Mutex<Config>,
config_mtime: Mutex<Option<SystemTime>>,
snapshot: Mutex<Snapshot>,
cache: Option<Cache>,
refresh_tx: Mutex<Option<mpsc::Sender<()>>>,
window_state: Mutex<Option<WindowState>>,
window_dirty: AtomicBool,
}
struct TrayItems {
always_on_top: CheckMenuItem<Wry>,
}
/// Static-ish facts the UI needs besides the snapshot.
#[derive(Serialize, Clone)]
struct Meta {
config_path: String,
cache_path: String,
opacity: f64,
always_on_top: bool,
platform: &'static str,
version: &'static str,
shortcut: String,
}
fn meta(app: &AppHandle) -> Meta {
let state = app.state::<AppState>();
let cfg = state.config.lock().unwrap();
Meta {
config_path: Config::path()
.map(|p| p.display().to_string())
.unwrap_or_default(),
cache_path: state
.cache
.as_ref()
.map(|c| c.path().display().to_string())
.unwrap_or_default(),
opacity: cfg.window.opacity,
always_on_top: cfg.window.always_on_top,
platform: std::env::consts::OS,
version: env!("CARGO_PKG_VERSION"),
shortcut: cfg.shortcuts.toggle_window.clone(),
}
}
// ───────────────────────────── commands ─────────────────────────────
#[tauri::command]
fn get_snapshot(state: State<AppState>) -> Snapshot {
state.snapshot.lock().unwrap().clone()
}
#[tauri::command]
fn get_meta(app: AppHandle) -> Meta {
meta(&app)
}
#[tauri::command]
fn refresh_now(state: State<AppState>) {
request_refresh(&state);
}
/// Only GitHub links leave the app, and they go to the default browser.
#[tauri::command]
fn open_url(url: String) -> Result<(), String> {
if !url.starts_with("https://github.com/") {
return Err(format!("refusing to open {url:?}"));
}
open_external(&url)
}
#[tauri::command]
fn open_config() -> Result<(), String> {
let path = Config::path().map_err(|e| e.to_string())?;
open_external(&path.display().to_string())
}
#[tauri::command]
fn set_always_on_top(app: AppHandle, on: bool) {
set_pinned(&app, on);
}
/// Called once the UI has painted, so the window never flashes empty.
#[tauri::command]
fn window_ready(app: AppHandle, state: State<AppState>) {
let start_hidden = state.config.lock().unwrap().window.start_hidden;
if !start_hidden {
if let Some(w) = app.get_webview_window(MAIN) {
show_window(&w);
}
}
}
#[tauri::command]
fn hide_window(app: AppHandle) {
if let Some(w) = app.get_webview_window(MAIN) {
let _ = w.hide();
}
}
#[tauri::command]
fn log(msg: String) {
eprintln!("[ui] {msg}");
}
#[tauri::command]
fn quit(app: AppHandle) {
flush_window_state(&app);
app.exit(0);
}
// ───────────────────────────── polling ─────────────────────────────
fn request_refresh(state: &AppState) {
if let Some(tx) = state.refresh_tx.lock().unwrap().as_ref() {
let _ = tx.send(());
}
}
fn emit_snapshot(app: &AppHandle) {
let snap = app.state::<AppState>().snapshot.lock().unwrap().clone();
let _ = app.emit(EV_SNAPSHOT, &snap);
}
fn config_mtime() -> Option<SystemTime> {
Config::path()
.ok()
.and_then(|p| std::fs::metadata(p).ok())
.and_then(|m| m.modified().ok())
}
/// Re-read the config if it changed on disk. A broken file keeps the old
/// config and surfaces the parse error in the window.
fn reload_config_if_changed(app: &AppHandle) {
let state = app.state::<AppState>();
let mtime = config_mtime();
if *state.config_mtime.lock().unwrap() == mtime {
return;
}
*state.config_mtime.lock().unwrap() = mtime;
match Config::load_or_create() {
Ok(cfg) => {
{
let mut snap = state.snapshot.lock().unwrap();
snap.sync_repos(&cfg.repos);
snap.poll_secs = cfg.poll_secs();
snap.stale_after_secs = cfg.stale_secs();
if snap
.error
.as_deref()
.is_some_and(|e| e.starts_with("Config"))
{
snap.error = None;
}
}
*state.config.lock().unwrap() = cfg.clone();
apply_window_config(app, &cfg);
let _ = app.emit(EV_META, meta(app));
}
Err(e) => {
state.snapshot.lock().unwrap().error = Some(format!("Config error: {e}"));
}
}
emit_snapshot(app);
}
/// Fetch every configured repository once. Returns seconds until the next poll.
fn run_cycle(app: &AppHandle) -> i64 {
let state = app.state::<AppState>();
let cfg = state.config.lock().unwrap().clone();
{
let mut snap = state.snapshot.lock().unwrap();
snap.sync_repos(&cfg.repos);
snap.refreshing = true;
// Measured afresh each cycle (the minimum any repo reported).
snap.rate_limit_remaining = None;
snap.poll_secs = cfg.poll_secs();
snap.stale_after_secs = cfg.stale_secs();
}
emit_snapshot(app);
let finish = |error: Option<String>, next_in: i64, all_ok: bool| {
{
let mut snap = state.snapshot.lock().unwrap();
snap.refreshing = false;
snap.error = error;
if all_ok {
snap.last_full_refresh_at = Some(Utc::now());
}
snap.next_refresh_at = Some(Utc::now() + chrono::Duration::seconds(next_in));
if let Some(cache) = &state.cache {
if let Err(e) = cache.save(&snap) {
eprintln!("could not save cache: {e}");
}
}
}
emit_snapshot(app);
update_tray(app);
next_in
};
let gh = match resolve_token(&cfg.github_token).and_then(GitHub::new) {
Ok(gh) => gh,
Err(e) => return finish(Some(e.to_string()), RETRY_AFTER_FAILURE * 5, false),
};
let queue = Mutex::new(cfg.repos.iter().rev().cloned().collect::<Vec<_>>());
let failures = Mutex::new(Vec::<GhError>::new());
std::thread::scope(|scope| {
for _ in 0..WORKERS.min(cfg.repos.len()) {
scope.spawn(|| loop {
let Some(repo) = queue.lock().unwrap().pop() else {
break;
};
let now = Utc::now();
let result = gh.fetch_repo(&repo, now);
{
let mut snap = state.snapshot.lock().unwrap();
let Some(entry) = snap.repos.iter_mut().find(|e| e.repo == repo) else {
continue;
};
entry.last_attempt_at = Some(now);
match result {
Ok((stats, budget)) => {
entry.stats = Some(stats);
entry.fetched_at = Some(now);
entry.error = None;
if let Some(left) = budget.graphql_remaining {
snap.rate_limit_remaining =
Some(snap.rate_limit_remaining.map_or(left, |r| r.min(left)));
}
}
Err(e) => {
entry.error = Some(e.to_string());
failures.lock().unwrap().push(e);
}
}
}
emit_snapshot(app);
});
}
});
let failures = failures.into_inner().unwrap();
let poll = cfg.poll_secs();
let rate_limited = failures
.iter()
.find(|e| matches!(e, GhError::RateLimited(_)));
if let Some(e) = rate_limited {
return finish(Some(e.to_string()), poll * 3, false);
}
if failures.iter().any(|e| matches!(e, GhError::Unauthorized)) {
return finish(Some(GhError::Unauthorized.to_string()), poll, false);
}
let low_budget = state
.snapshot
.lock()
.unwrap()
.rate_limit_remaining
.is_some_and(|r| r < BUDGET_FLOOR);
if failures.len() == cfg.repos.len() && !cfg.repos.is_empty() {
let msg = failures.first().map(|e| e.to_string());
return finish(msg, RETRY_AFTER_FAILURE, false);
}
let next = if low_budget { poll.max(30 * 60) } else { poll };
let error = low_budget.then(|| "GitHub API budget low; polling less often".to_string());
finish(error, next, failures.is_empty())
}
/// When the cached data is younger than one interval, wait out the rest of it
/// instead of polling right at launch (relaunching shouldn't cost a poll).
fn first_due(snap: &Snapshot, poll_secs: i64) -> DateTime<Utc> {
let now = Utc::now();
match snap.oldest_data_at() {
Some(t) if (now - t).num_seconds() < poll_secs => t + chrono::Duration::seconds(poll_secs),
_ => now,
}
}
fn spawn_poller(app: AppHandle, rx: mpsc::Receiver<()>) {
std::thread::Builder::new()
.name("craft-poller".into())
.spawn(move || {
let state = app.state::<AppState>();
let mut next_due = {
let poll = state.config.lock().unwrap().poll_secs();
let mut snap = state.snapshot.lock().unwrap();
let due = first_due(&snap, poll);
snap.next_refresh_at = Some(due);
due
};
let mut last_done: Option<DateTime<Utc>> = None;
let mut manual = false;
loop {
reload_config_if_changed(&app);
let now = Utc::now();
let debounced =
last_done.is_some_and(|t| (now - t).num_seconds() < MANUAL_DEBOUNCE);
if now >= next_due || (manual && !debounced) {
let next_in = run_cycle(&app);
last_done = Some(Utc::now());
next_due = Utc::now() + chrono::Duration::seconds(next_in);
} else {
// Staleness changes with the clock alone.
update_tray(&app);
}
manual = false;
flush_window_state(&app);
match rx.recv_timeout(TICK) {
Ok(()) => {
manual = true;
while rx.try_recv().is_ok() {}
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => std::thread::sleep(TICK),
}
}
})
.expect("spawn poll thread");
}
// ───────────────────────────── window & tray ─────────────────────────────
fn open_external(target: &str) -> Result<(), String> {
use std::process::Command;
#[cfg(target_os = "macos")]
let mut cmd = {
let mut c = Command::new("open");
c.arg(target);
c
};
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = Command::new("cmd");
c.args(["/C", "start", ""]).arg(target);
c
};
#[cfg(all(unix, not(target_os = "macos")))]
let mut cmd = {
let mut c = Command::new("xdg-open");
c.arg(target);
c
};
cmd.spawn().map(|_| ()).map_err(|e| e.to_string())
}
fn show_window(w: &WebviewWindow) {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
let _ = w.emit(EV_FOCUS, true);
}
/// Tray click / global hotkey: hide if focused, otherwise bring to front on
/// the current desktop.
fn toggle_window(app: &AppHandle) {
let Some(w) = app.get_webview_window(MAIN) else {
return;
};
if w.is_visible().unwrap_or(false) && w.is_focused().unwrap_or(false) {
let _ = w.hide();
} else {
show_window(&w);
}
}
fn set_pinned(app: &AppHandle, on: bool) {
let state = app.state::<AppState>();
state.config.lock().unwrap().window.always_on_top = on;
if let Some(w) = app.get_webview_window(MAIN) {
let _ = w.set_always_on_top(on);
}
if let Some(items) = app.try_state::<TrayItems>() {
let _ = items.always_on_top.set_checked(on);
}
let _ = app.emit(EV_META, meta(app));
}
fn apply_window_config(app: &AppHandle, cfg: &Config) {
if let Some(w) = app.get_webview_window(MAIN) {
let _ = w.set_always_on_top(cfg.window.always_on_top);
let _ = w.set_visible_on_all_workspaces(cfg.tray.visible_on_all_workspaces);
#[cfg(not(target_os = "macos"))]
let _ = w.set_skip_taskbar(true);
}
if let Some(items) = app.try_state::<TrayItems>() {
let _ = items.always_on_top.set_checked(cfg.window.always_on_top);
}
apply_shortcut(app, cfg);
}
fn apply_shortcut(app: &AppHandle, cfg: &Config) {
let gs = app.global_shortcut();
let _ = gs.unregister_all();
let accel = cfg.shortcuts.toggle_window.trim();
if accel.is_empty() {
return;
}
let result = gs.on_shortcut(accel, |app, _shortcut, event| {
if event.state() == ShortcutState::Pressed {
toggle_window(app);
}
});
if let Err(e) = result {
eprintln!("Could not register shortcut {accel:?}: {e}");
}
}
/// "Craft Status — updated 14:05" or "… — STALE (updated 13:20)".
fn tray_tooltip(snap: &Snapshot, now: DateTime<Utc>) -> String {
let when = snap
.oldest_data_at()
.map(|t| t.with_timezone(&Local).format("%H:%M").to_string());
match (when, snap.is_stale(now)) {
(None, _) => "Craft Status — no data yet".into(),
(Some(w), true) => format!("Craft Status — STALE (data from {w})"),
(Some(w), false) => format!("Craft Status — updated {w}"),
}
}
fn update_tray(app: &AppHandle) {
let tip = tray_tooltip(
&app.state::<AppState>().snapshot.lock().unwrap(),
Utc::now(),
);
if let Some(tray) = app.tray_by_id(TRAY) {
let _ = tray.set_tooltip(Some(tip));
}
}
fn note_window_geometry(app: &AppHandle) {
let Some(w) = app.get_webview_window(MAIN) else {
return;
};
let (Ok(pos), Ok(size)) = (w.outer_position(), w.outer_size()) else {
return;
};
if size.width == 0 || size.height == 0 || !w.is_visible().unwrap_or(false) {
return;
}
let state = app.state::<AppState>();
*state.window_state.lock().unwrap() = Some(WindowState {
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
});
state.window_dirty.store(true, Ordering::Relaxed);
}
fn flush_window_state(app: &AppHandle) {
let state = app.state::<AppState>();
if !state.window_dirty.swap(false, Ordering::Relaxed) {
return;
}
let saved = *state.window_state.lock().unwrap();
if let Some(ws) = saved {
if let Err(e) = ws.save() {
eprintln!("could not save window state: {e}");
}
}
}
fn restore_window(app: &tauri::App, cfg: &Config) {
let Some(w) = app.get_webview_window(MAIN) else {
return;
};
let monitors: Vec<(i32, i32, u32, u32)> = app
.available_monitors()
.unwrap_or_default()
.iter()
.map(|m| {
(
m.position().x,
m.position().y,
m.size().width,
m.size().height,
)
})
.collect();
if let Some(ws) = WindowState::load().filter(|ws| ws.visible_on(&monitors)) {
let _ = w.set_size(tauri::PhysicalSize::new(ws.width, ws.height));
let _ = w.set_position(tauri::PhysicalPosition::new(ws.x, ws.y));
*app.state::<AppState>().window_state.lock().unwrap() = Some(ws);
} else {
let _ = w.set_size(LogicalSize::new(cfg.window.width, cfg.window.height));
let _ = w.center();
}
}
fn build_tray(app: &tauri::App, cfg: &Config) -> tauri::Result<()> {
let toggle = MenuItem::with_id(app, "toggle", "Show / Hide", true, None::<&str>)?;
let refresh = MenuItem::with_id(app, "refresh", "Refresh Now", true, None::<&str>)?;
let on_top = CheckMenuItem::with_id(
app,
"on_top",
"Always on Top",
true,
cfg.window.always_on_top,
None::<&str>,
)?;
let open_cfg = MenuItem::with_id(app, "open_config", "Edit Config…", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit Craft Status", true, None::<&str>)?;
let menu = Menu::with_items(
app,
&[
&toggle,
&refresh,
&on_top,
&PredefinedMenuItem::separator(app)?,
&open_cfg,
&PredefinedMenuItem::separator(app)?,
&quit,
],
)?;
app.manage(TrayItems {
always_on_top: on_top.clone(),
});
#[cfg(target_os = "macos")]
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
#[cfg(not(target_os = "macos"))]
let icon = app
.default_window_icon()
.cloned()
.unwrap_or(tauri::image::Image::from_bytes(include_bytes!(
"../icons/32x32.png"
))?);
TrayIconBuilder::with_id(TRAY)
.icon(icon)
.icon_as_template(true)
.tooltip("Craft Status")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"toggle" => toggle_window(app),
"refresh" => request_refresh(&app.state::<AppState>()),
"on_top" => {
let checked = app
.try_state::<TrayItems>()
.and_then(|t| t.always_on_top.is_checked().ok())
.unwrap_or(false);
set_pinned(app, checked);
}
"open_config" => {
let _ = open_config();
}
"quit" => {
flush_window_state(app);
app.exit(0)
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
toggle_window(tray.app_handle());
}
})
.build(app)?;
Ok(())
}
pub fn run() {
let config = Config::load_or_create().unwrap_or_else(|e| {
eprintln!("Could not load ~/.craft_status_config.toml ({e}); using defaults");
Config::default()
});
let cache = Cache::default_location().ok();
let mut snapshot = cache.as_ref().and_then(Cache::load).unwrap_or_default();
snapshot.sync_repos(&config.repos);
snapshot.poll_secs = config.poll_secs();
snapshot.stale_after_secs = config.stale_secs();
let (tx, rx) = mpsc::channel();
let state = AppState {
config: Mutex::new(config),
config_mtime: Mutex::new(config_mtime()),
snapshot: Mutex::new(snapshot),
cache,
refresh_tx: Mutex::new(Some(tx)),
window_state: Mutex::new(None),
window_dirty: AtomicBool::new(false),
};
tauri::Builder::default()
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.manage(state)
.invoke_handler(tauri::generate_handler![
get_snapshot,
get_meta,
refresh_now,
open_url,
open_config,
set_always_on_top,
window_ready,
hide_window,
log,
quit,
])
.setup(move |app| {
let cfg = app.state::<AppState>().config.lock().unwrap().clone();
#[cfg(target_os = "macos")]
if cfg.tray.hide_dock_icon {
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
}
restore_window(app, &cfg);
build_tray(app, &cfg)?;
apply_window_config(app.handle(), &cfg);
update_tray(app.handle());
spawn_poller(app.handle().clone(), rx);
Ok(())
})
.on_window_event(|window, event| match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
let close_to_tray = window
.state::<AppState>()
.config
.lock()
.unwrap()
.tray
.close_to_tray;
flush_window_state(window.app_handle());
if close_to_tray {
api.prevent_close();
let _ = window.hide();
}
}
tauri::WindowEvent::Focused(focused) => {
let _ = window.emit(EV_FOCUS, *focused);
}
tauri::WindowEvent::Resized(_) | tauri::WindowEvent::Moved(_) => {
note_window_geometry(window.app_handle())
}
_ => {}
})
.run(tauri::generate_context!())
.expect("error while running Craft Status");
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration as CDuration;
fn snap_with(ages_mins: &[i64], now: DateTime<Utc>) -> Snapshot {
let mut s = Snapshot {
stale_after_secs: 15 * 60,
..Default::default()
};
let names: Vec<String> = (0..ages_mins.len()).map(|i| format!("o/r{i}")).collect();
s.sync_repos(&names);
for (e, m) in s.repos.iter_mut().zip(ages_mins) {
e.fetched_at = Some(now - CDuration::minutes(*m));
}
s
}
#[test]
fn first_poll_waits_for_fresh_cache_only() {
let now = Utc::now();
let fresh = snap_with(&[1, 2], now);
let due = first_due(&fresh, 300);
assert!(due > now + CDuration::seconds(150) && due <= now + CDuration::seconds(181));
let old = snap_with(&[1, 20], now);
assert!(first_due(&old, 300) <= Utc::now());
assert!(first_due(&Snapshot::default(), 300) <= Utc::now());
}
#[test]
fn tooltip_reports_staleness() {
let now = Utc::now();
assert!(tray_tooltip(&Snapshot::default(), now).contains("no data"));
assert!(tray_tooltip(&snap_with(&[1], now), now).contains("updated"));
assert!(tray_tooltip(&snap_with(&[1, 30], now), now).contains("STALE"));
}
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents an extra console window on Windows in release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
craft_status_app_lib::run()
}
+49
View File
@@ -0,0 +1,49 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Craft Status",
"version": "0.1.0",
"identifier": "ai.storyteller.craft-status",
"build": {
"frontendDist": "../../ui"
},
"app": {
"withGlobalTauri": true,
"macOSPrivateApi": true,
"windows": [
{
"label": "main",
"title": "Craft Status",
"width": 1340,
"height": 560,
"minWidth": 520,
"minHeight": 240,
"decorations": false,
"transparent": true,
"shadow": true,
"resizable": true,
"visible": false,
"center": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"category": "DeveloperTool",
"shortDescription": "Status of the storytold craft repositories",
"longDescription": "A tray dashboard that polls the craft repositories on GitHub every few minutes: PRs, issues, commits, builds, downloads, and the most urgent fixes.",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"minimumSystemVersion": "10.15"
}
}
}
+282
View File
@@ -0,0 +1,282 @@
// Craft Status UI: renders the snapshot pushed by the Rust poller.
// Outside Tauri (plain browser) it loads ./dev-snapshot.json, written by
// `make snapshot`, so the UI can be iterated on without the app.
import { COLUMNS, value, sortEntries, defaultDir, freshness, ago, totals, urgentAcross } from './model.js';
const $ = (id) => document.getElementById(id);
const TAURI = window.__TAURI__;
const api = TAURI
? {
invoke: (cmd, args) => TAURI.core.invoke(cmd, args),
listen: (ev, fn) => TAURI.event.listen(ev, (e) => fn(e.payload)),
startDrag: () => TAURI.window.getCurrentWindow().startDragging(),
startResize: () => TAURI.window.getCurrentWindow().startResizeDragging('SouthEast'),
}
: {
async invoke(cmd, args) {
if (cmd === 'get_snapshot') return (await fetch('dev-snapshot.json')).json();
if (cmd === 'get_meta') return { opacity: 0.96, always_on_top: false, shortcut: 'CmdOrCtrl+Shift+G' };
if (cmd === 'open_url') window.open(args.url, '_blank');
return null;
},
listen() {},
startDrag() {},
startResize() {},
};
const load = (k, d) => {
try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; }
};
const save = (k, v) => {
try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* private mode */ }
};
const state = {
snap: null,
meta: {},
view: new URLSearchParams(location.search).get('view') ?? load('view', 'repos'),
sort: load('sort', { key: 'urgency', dir: 'desc' }),
urgentRepo: '',
criticalOnly: load('criticalOnly', false),
};
// ───────────────────────────── formatting ─────────────────────────────
const el = (tag, props = {}, ...kids) => {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (v === undefined || v === null || v === false) continue;
if (k === 'class') n.className = v;
else if (k === 'dataset') Object.assign(n.dataset, v);
else if (k.startsWith('on')) n.addEventListener(k.slice(2), v);
else n.setAttribute(k, v);
}
for (const kid of kids.flat()) if (kid !== null && kid !== undefined && kid !== false) n.append(kid);
return n;
};
const localTime = (iso) => (iso ? new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : '');
const clock = (t) => new Date(t).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const num = (n) => (n ?? 0).toLocaleString('en-US');
const gh = (repo, path = '') => `https://github.com/${repo}${path}`;
const DAY = 86400e3;
function openUrl(url, ev) {
ev?.stopPropagation();
api.invoke('open_url', { url }).catch((e) => console.warn(e));
}
const link = (text, url, cls = '') => el('span', { class: `link ${cls}`, onclick: (e) => openUrl(url, e) }, text);
/** Cell content for a column. */
function cell(entry, col) {
const v = value(entry, col.key);
const s = entry.stats;
if (col.key === 'name') {
const tip = entry.error
? `Last attempt failed: ${entry.error}`
: entry.fetched_at ? `Fetched ${localTime(entry.fetched_at)}` : 'Not fetched yet';
return el('span', { class: 'repo', title: tip }, el('span', { class: 'dot' }), link(v, gh(entry.repo)));
}
if (!s) return el('span', { class: 'muted' }, '—');
switch (col.key) {
case 'urgency': {
const a = Math.min(1, v / 350) * 0.45;
return el('span', { class: 'urgency', style: `background: rgb(var(--hot-rgb) / ${a.toFixed(2)})` }, Math.round(v));
}
case 'critical_issues': return el('span', { class: v ? 'crit' : 'zero' }, num(v));
case 'open_prs': return link(num(v), gh(entry.repo, '/pulls'));
case 'open_issues': return link(num(v), gh(entry.repo, '/issues'));
case 'commits_4h': case 'prs_4h': case 'issues_4h':
return el('span', { class: v ? 'active' : 'zero' }, num(v));
case 'latest_build':
if (!v) return el('span', { class: 'muted' }, '—');
return el('span', {}, link(v, gh(entry.repo, `/releases/tag/${encodeURIComponent(v)}`)),
s.latest_build_prerelease ? el('span', { class: 'pre' }, 'PRE') : null);
}
if (col.kind === 'date') {
if (!v) return el('span', { class: 'muted' }, '—');
const old = col.key === 'oldest_open_pr_at' && Date.now() - Date.parse(v) > 14 * DAY;
return el('span', { title: localTime(v), class: old ? 'old' : '' }, ago(v));
}
return document.createTextNode(num(v));
}
// ───────────────────────────── rendering ─────────────────────────────
function renderHead() {
const groups = el('tr', { class: 'groups' });
let prev = null;
for (const c of COLUMNS) {
if (c.group === prev) { groups.lastChild.colSpan += 1; continue; }
prev = c.group;
groups.append(el('th', { class: c.group ? 'group-start' : '' }, c.group));
}
const cols = el('tr', { class: 'cols' });
COLUMNS.forEach((c, i) => {
const sorted = state.sort.key === c.key;
const start = i > 0 && COLUMNS[i - 1].group !== c.group;
cols.append(el('th', {
title: c.title ?? `Sort by ${c.label}`,
class: [sorted && 'sorted', sorted && state.sort.dir === 'asc' && 'asc', start && 'group-start'].filter(Boolean).join(' '),
onclick: () => {
state.sort = state.sort.key === c.key
? { key: c.key, dir: state.sort.dir === 'asc' ? 'desc' : 'asc' }
: { key: c.key, dir: defaultDir(c.key) };
save('sort', state.sort);
render();
},
}, c.label));
});
$('thead').replaceChildren(groups, cols);
}
function groupStart(i) {
return i > 0 && COLUMNS[i - 1].group !== COLUMNS[i].group ? 'group-start' : '';
}
function renderTable() {
renderHead();
const snap = state.snap;
const rows = sortEntries(snap.repos, state.sort.key, state.sort.dir);
const now = Date.now();
$('tbody').replaceChildren(...rows.map((e) => {
const stale = !e.fetched_at || now - Date.parse(e.fetched_at) > snap.stale_after_secs * 1000;
return el('tr', {
class: [stale && 'stale', e.error && 'error'].filter(Boolean).join(' '),
title: 'Click for this repo’s most urgent issues',
onclick: () => { state.urgentRepo = e.repo; setView('urgent'); },
}, COLUMNS.map((c, i) => el('td', { class: groupStart(i) }, cell(e, c))));
}));
if (!rows.length) {
$('tbody').replaceChildren(el('tr', {}, el('td', { colspan: COLUMNS.length, class: 'empty' }, 'No repositories configured — Edit config.')));
}
const t = totals(snap.repos);
$('tfoot').replaceChildren(el('tr', {}, COLUMNS.map((c, i) =>
el('td', { class: groupStart(i) }, i === 0 ? `${snap.repos.length} repos` : c.kind === 'num' ? num(Math.round(t[c.key])) : ''))));
}
function renderUrgent() {
const snap = state.snap;
const sel = $('urgent-repo');
sel.replaceChildren(el('option', { value: '' }, 'All repositories'),
...snap.repos.map((r) => el('option', { value: r.repo }, r.repo.split('/').pop())));
sel.value = state.urgentRepo;
$('critical-only').checked = state.criticalOnly;
const items = urgentAcross(snap.repos, { repo: state.urgentRepo, criticalOnly: state.criticalOnly });
const SEVERE = /crash|panic|freeze|hang|data loss|corruption|won't|blank|security/;
$('urgent-list').replaceChildren(...items.map((u) => {
const a = Math.min(1, u.score / 90) * 0.5;
return el('li', { class: `urgent-item${u.critical ? ' critical' : ''}`, onclick: () => openUrl(u.url), title: u.url },
el('span', { class: 'score', style: `background: rgb(var(--hot-rgb) / ${a.toFixed(2)})` }, u.score),
el('span', { class: 'u-repo' }, u.repo.split('/').pop()),
el('div', { class: 'u-title' },
el('span', { class: 'num' }, `#${u.number}`), el('span', { class: 'text' }, u.title),
el('div', { class: 'reasons' }, u.reasons.map((r) => el('span', { class: `reason${SEVERE.test(r) ? ' sev' : ''}` }, r)),
u.labels.filter((l) => !u.reasons.includes(`${l} label`)).map((l) => el('span', { class: 'reason' }, l)))),
el('span', { class: 'u-age', title: `Opened ${localTime(u.created_at)}` }, ago(u.created_at)));
}));
if (!items.length) {
$('urgent-list').replaceChildren(el('li', { class: 'empty' }, snap.repos.some((r) => r.stats) ? 'Nothing urgent. 🎉' : 'Waiting for the first poll…'));
}
}
function renderStatus() {
const snap = state.snap;
const { oldest, stale } = freshness(snap);
const pill = $('fresh-pill');
if (snap.refreshing) { pill.className = 'pill refreshing'; pill.textContent = 'REFRESHING'; }
else if (stale) { pill.className = 'pill stale'; pill.textContent = 'STALE'; }
else { pill.className = 'pill fresh'; pill.textContent = 'FRESH'; }
const last = snap.last_full_refresh_at ? Date.parse(snap.last_full_refresh_at) : null;
let text;
if (oldest === null) text = last ? `Partial data · last full refresh ${clock(last)}` : 'No data yet';
else {
text = `Data as of ${clock(oldest)} (${ago(new Date(oldest).toISOString())} ago)`;
if (last && Math.abs(last - oldest) > 60e3) text += ` · last full refresh ${clock(last)}`;
}
$('refreshed').textContent = text;
$('refreshed').title = [
`Oldest data on screen: ${oldest ? localTime(new Date(oldest).toISOString()) : '—'}`,
`Last full refresh: ${localTime(snap.last_full_refresh_at) || '—'}`,
`Stale after ${Math.round(snap.stale_after_secs / 60)} min without new data`,
].join('\n');
$('refresh').classList.toggle('spinning', !!snap.refreshing);
const failing = snap.repos.filter((r) => r.error);
const msgs = [snap.error, failing.length ? `${failing.length} repo${failing.length > 1 ? 's' : ''} failed last poll: ${failing.map((r) => `${r.repo.split('/').pop()} (${r.error})`).join('; ')}` : null].filter(Boolean);
$('banner').hidden = !msgs.length;
$('banner').textContent = msgs.join(' — ');
$('next').textContent = snap.refreshing ? 'Polling GitHub…'
: snap.next_refresh_at ? `Next poll ${clock(Date.parse(snap.next_refresh_at))} · every ${Math.round(snap.poll_secs / 60)} min` : '';
$('budget').textContent = snap.rate_limit_remaining != null ? `GitHub budget ${num(snap.rate_limit_remaining)}` : '';
const critical = snap.repos.reduce((n, r) => n + (r.stats?.critical_issues ?? 0), 0);
$('urgent-count').textContent = critical ? String(critical) : '';
$('urgent-count').title = `${critical} critical open issues`;
$('repo-count').textContent = String(snap.repos.length);
}
function render() {
if (!state.snap) return;
renderStatus();
document.querySelectorAll('.view').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.view === state.view)));
$('repos-view').hidden = state.view !== 'repos';
$('urgent-view').hidden = state.view !== 'urgent';
if (state.view === 'repos') renderTable(); else renderUrgent();
}
function setView(v) {
state.view = v;
save('view', v);
render();
}
function applyMeta(m) {
state.meta = m ?? {};
document.documentElement.style.setProperty('--opacity', String(state.meta.opacity ?? 0.96));
$('pin').setAttribute('aria-pressed', String(!!state.meta.always_on_top));
const mac = navigator.platform.toLowerCase().includes('mac');
$('shortcut').textContent = (state.meta.shortcut ?? '').replace('CmdOrCtrl', mac ? '⌘' : 'Ctrl').replace('Shift', '⇧').replaceAll('+', '');
}
// ───────────────────────────── wiring ─────────────────────────────
document.querySelectorAll('.view').forEach((b) => b.addEventListener('click', () => {
if (b.dataset.view === 'urgent' && state.view !== 'urgent') state.urgentRepo = '';
setView(b.dataset.view);
}));
$('urgent-repo').addEventListener('change', (e) => { state.urgentRepo = e.target.value; render(); });
$('critical-only').addEventListener('change', (e) => { state.criticalOnly = e.target.checked; save('criticalOnly', state.criticalOnly); render(); });
$('refresh').addEventListener('click', () => api.invoke('refresh_now'));
$('pin').addEventListener('click', () => api.invoke('set_always_on_top', { on: !state.meta.always_on_top }));
$('hide').addEventListener('click', () => api.invoke('hide_window'));
$('edit-config').addEventListener('click', () => api.invoke('open_config'));
$('resize').addEventListener('mousedown', (e) => { e.preventDefault(); api.startResize(); });
document.addEventListener('keydown', (e) => {
const mod = e.metaKey || e.ctrlKey;
if (e.key === 'Escape') api.invoke('hide_window');
else if (mod && e.key.toLowerCase() === 'r') { e.preventDefault(); api.invoke('refresh_now'); }
else if (mod && e.key === '1') setView('repos');
else if (mod && e.key === '2') setView('urgent');
});
api.listen('craft:snapshot', (s) => { state.snap = s; render(); });
api.listen('craft:meta', applyMeta);
// Relative times and staleness change with the clock alone.
setInterval(render, 15_000);
(async () => {
try {
applyMeta(await api.invoke('get_meta'));
state.snap = await api.invoke('get_snapshot');
render();
} catch (e) {
console.error(e);
} finally {
// Not requestAnimationFrame: WebKit never fires it while the window is hidden.
api.invoke('window_ready');
}
})();
+72
View File
@@ -0,0 +1,72 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Craft Status</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app" id="app">
<header data-tauri-drag-region>
<div class="brand" data-tauri-drag-region>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 14V9M6 14V6M10 14V4M14 14V1"/></svg>
<span data-tauri-drag-region>Craft Status</span>
</div>
<nav class="views" role="tablist">
<button class="view" data-view="repos" role="tab">Repos <span class="badge" id="repo-count"></span></button>
<button class="view" data-view="urgent" role="tab">Fix first <span class="badge hot" id="urgent-count"></span></button>
</nav>
<div class="status" data-tauri-drag-region>
<span id="fresh-pill" class="pill"></span>
<span id="refreshed" class="refreshed" data-tauri-drag-region></span>
</div>
<div class="actions">
<button class="icon-button" id="refresh" title="Refresh now (⌘R)">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2v3h-3"/></svg>
</button>
<button class="icon-button" id="pin" title="Always on top">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 2h4l-.5 4 2.5 2.5H4L6.5 6zM8 8.5V14"/></svg>
</button>
<button class="icon-button" id="hide" title="Hide (Esc)">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>
</button>
</div>
</header>
<div class="banner" id="banner" hidden></div>
<main id="repos-view">
<table id="table">
<thead id="thead"></thead>
<tbody id="tbody"></tbody>
<tfoot id="tfoot"></tfoot>
</table>
</main>
<main id="urgent-view" hidden>
<div class="urgent-controls">
<select id="urgent-repo" aria-label="Repository"></select>
<label class="toggle"><input type="checkbox" id="critical-only"> Critical only</label>
<span class="hint">Scored by severity words, labels, user reports, 👍/💬, the latest build and freshness. Click to open on GitHub.</span>
</div>
<ol class="urgent-list" id="urgent-list"></ol>
</main>
<footer data-tauri-drag-region>
<span id="next" data-tauri-drag-region></span>
<span id="budget" data-tauri-drag-region></span>
<span class="spacer" data-tauri-drag-region></span>
<button class="footer-link" id="edit-config">Edit config</button>
<span id="shortcut"></span>
</footer>
<div id="resize" title="Resize"></div>
</div>
<script>
// Surface load/runtime errors in the app's stderr (module errors never reach app.js).
const report = (m) => window.__TAURI__?.core.invoke('log', { msg: String(m) });
addEventListener('error', (e) => report(`${e.message} @ ${e.filename}:${e.lineno}`));
addEventListener('unhandledrejection', (e) => report(`unhandled: ${e.reason}`));
</script>
<script type="module" src="app.js"></script>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
// Pure helpers: columns, sorting, formatting, staleness. No DOM, so
// `node --test ui/` can exercise them (see model.test.mjs).
/** @typedef {'text'|'num'|'date'|'version'} Kind */
/**
* Table columns, in display order. `get` reads from a RepoEntry; numbers and
* dates sort descending first (biggest / newest on top), text ascending.
*/
export const COLUMNS = [
{ key: 'name', label: 'Repo', kind: 'text', group: '', get: (e) => e.repo.split('/').pop() },
{ key: 'urgency', label: 'Urgency', kind: 'num', group: 'Fix first', title: 'Sum of the top five open-issue urgency scores' },
{ key: 'critical_issues', label: 'Critical', kind: 'num', group: 'Fix first', title: 'Open issues that mention a crash, hang, freeze, data loss, launch failure or security problem' },
{ key: 'open_prs', label: 'PRs', kind: 'num', group: 'Open', title: 'Open pull requests' },
{ key: 'open_issues', label: 'Issues', kind: 'num', group: 'Open', title: 'Open issues' },
{ key: 'oldest_open_pr_at', label: 'Oldest PR', kind: 'date', group: 'Open', title: 'Oldest pull request still open', asc: true },
{ key: 'last_commit_at', label: 'Last commit', kind: 'date', group: 'Latest', title: 'Last commit on main' },
{ key: 'last_issue_at', label: 'Last issue', kind: 'date', group: 'Latest', title: 'Newest issue (any state)' },
{ key: 'newest_pr_at', label: 'Newest PR', kind: 'date', group: 'Latest', title: 'Newest pull request (any state)' },
{ key: 'commits_4h', label: 'Commits', kind: 'num', group: 'Last 4 h', title: 'Commits to main in the last 4 hours' },
{ key: 'prs_4h', label: 'PRs', kind: 'num', group: 'Last 4 h', title: 'Pull requests opened in the last 4 hours' },
{ key: 'issues_4h', label: 'Issues', kind: 'num', group: 'Last 4 h', title: 'Issues opened in the last 4 hours' },
{ key: 'commits_total', label: 'Commits', kind: 'num', group: 'All time', title: 'Commits on main, all time' },
{ key: 'contributors', label: 'People', kind: 'num', group: 'All time', title: 'Contributors' },
{ key: 'issues_total', label: 'Issues', kind: 'num', group: 'All time', title: 'Issues, all time, open + closed' },
{ key: 'prs_total', label: 'PRs', kind: 'num', group: 'All time', title: 'Pull requests, all time, open + closed + merged' },
{ key: 'latest_build', label: 'Build', kind: 'version', group: 'Builds', title: 'Most recent published release' },
{ key: 'latest_build_at', label: 'Built', kind: 'date', group: 'Builds', title: 'When the most recent release was published' },
{ key: 'latest_build_downloads', label: 'Build DLs', kind: 'num', group: 'Builds', title: 'Downloads of the most recent release, all OSes and package types (checksums and update deltas excluded)' },
{ key: 'downloads_total', label: 'Total DLs', kind: 'num', group: 'Builds', title: 'Downloads across every published release' },
];
export const COLUMN = Object.fromEntries(COLUMNS.map((c) => [c.key, c]));
/** The value a column shows for an entry; `null` when there is no data. */
export function value(entry, key) {
const col = COLUMN[key];
if (col?.get) return col.get(entry);
const v = entry.stats ? entry.stats[key] : null;
return v === undefined ? null : v;
}
/** `v0.10.2-rc.1` → [0, 10, 2, -1, 1] so pre-releases sort below the release. */
export function versionKey(tag) {
const m = String(tag).replace(/^v/i, '').match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([A-Za-z]*)\.?(\d+)?)?/);
if (!m) return [-1];
return [+m[1], +(m[2] ?? 0), +(m[3] ?? 0), m[4] !== undefined ? -1 : 0, +(m[5] ?? 0)];
}
function cmpArrays(a, b) {
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const d = (a[i] ?? 0) - (b[i] ?? 0);
if (d) return d;
}
return 0;
}
function cmpValues(kind, a, b) {
switch (kind) {
case 'text': return String(a).localeCompare(String(b), undefined, { sensitivity: 'base', numeric: true });
case 'date': return Date.parse(a) - Date.parse(b);
case 'version': return cmpArrays(versionKey(a), versionKey(b));
default: return a - b;
}
}
/** The direction a column sorts in when first clicked. */
export function defaultDir(key) {
const col = COLUMN[key];
return col.kind === 'text' || col.asc ? 'asc' : 'desc';
}
/** Sorted copy. Missing values always sink to the bottom; ties fall back to name. */
export function sortEntries(entries, key, dir) {
const col = COLUMN[key] ?? COLUMN.urgency;
const sign = dir === 'asc' ? 1 : -1;
return [...entries].sort((a, b) => {
const va = value(a, col.key);
const vb = value(b, col.key);
if (va === null && vb === null) return cmpValues('text', value(a, 'name'), value(b, 'name'));
if (va === null) return 1;
if (vb === null) return -1;
return sign * cmpValues(col.kind, va, vb) || cmpValues('text', value(a, 'name'), value(b, 'name'));
});
}
export function isStale(entry, staleAfterSecs, now = Date.now()) {
if (!entry.fetched_at) return true;
return now - Date.parse(entry.fetched_at) > staleAfterSecs * 1000;
}
/** `{ oldest, stale }` for the whole snapshot: the age of the oldest data on screen. */
export function freshness(snap, now = Date.now()) {
const times = snap.repos.map((r) => (r.fetched_at ? Date.parse(r.fetched_at) : null));
const oldest = times.length && times.every((t) => t !== null) ? Math.min(...times) : null;
const stale = !snap.repos.length || snap.repos.some((r) => isStale(r, snap.stale_after_secs, now));
return { oldest, stale };
}
/** "now", "45s", "12m", "3h", "5d", "2y" — compact for table cells. */
export function ago(iso, now = Date.now()) {
if (!iso) return '';
const s = Math.max(0, (now - Date.parse(iso)) / 1000);
if (s < 10) return 'now';
if (s < 60) return `${Math.floor(s)}s`;
if (s < 3600) return `${Math.floor(s / 60)}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
if (s < 86400 * 365) return `${Math.floor(s / 86400)}d`;
return `${Math.floor(s / (86400 * 365))}y`;
}
export function compact(n) {
if (n === null || n === undefined) return '';
if (n < 10000) return n.toLocaleString('en-US');
if (n < 1e6) return `${(n / 1000).toFixed(n < 1e5 ? 1 : 0)}k`;
return `${(n / 1e6).toFixed(1)}M`;
}
/** Column totals for the footer row (counts only; dates and versions blank). */
export function totals(entries) {
const out = {};
for (const c of COLUMNS) {
if (c.kind !== 'num') continue;
out[c.key] = entries.reduce((sum, e) => sum + (value(e, c.key) ?? 0), 0);
}
return out;
}
/** Every repo's urgent issues merged, best first. */
export function urgentAcross(entries, { repo = '', criticalOnly = false, limit = 60 } = {}) {
const all = [];
for (const e of entries) {
if (repo && e.repo !== repo) continue;
for (const u of e.stats?.urgent ?? []) {
if (criticalOnly && !u.critical) continue;
all.push({ ...u, repo: e.repo });
}
}
all.sort((a, b) => b.score - a.score || Date.parse(b.created_at ?? 0) - Date.parse(a.created_at ?? 0));
return all.slice(0, limit);
}
+63
View File
@@ -0,0 +1,63 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sortEntries, versionKey, freshness, ago, compact, totals, urgentAcross, defaultDir } from './model.js';
const entry = (repo, stats, fetched_at = '2026-10-08T12:00:00Z') => ({ repo: `o/${repo}`, stats, fetched_at });
const rows = [
entry('b', { urgency: 10, latest_build: 'v0.10.0', last_commit_at: '2026-10-08T10:00:00Z' }),
entry('a', { urgency: 50, latest_build: 'v0.9.0', last_commit_at: '2026-10-08T11:00:00Z' }),
entry('c', null, null),
entry('d', { urgency: 50, latest_build: 'v0.10.0-rc.2', last_commit_at: null }),
];
const names = (list) => list.map((e) => e.repo.slice(2)).join('');
test('numbers sort descending with ties by name, missing last', () => {
assert.equal(names(sortEntries(rows, 'urgency', 'desc')), 'adbc');
assert.equal(names(sortEntries(rows, 'urgency', 'asc')), 'badc');
});
test('versions sort semantically, prereleases below their release', () => {
assert.equal(names(sortEntries(rows, 'latest_build', 'desc')), 'bdac');
assert.deepEqual(versionKey('v0.10.0-rc.2'), [0, 10, 0, -1, 2]);
assert.deepEqual(versionKey('nightly'), [-1]);
});
test('dates and names', () => {
assert.equal(names(sortEntries(rows, 'last_commit_at', 'desc')), 'abcd');
assert.equal(names(sortEntries(rows, 'name', 'asc')), 'abcd');
assert.equal(defaultDir('name'), 'asc');
assert.equal(defaultDir('downloads_total'), 'desc');
assert.equal(defaultDir('oldest_open_pr_at'), 'asc');
});
test('freshness', () => {
const now = Date.parse('2026-10-08T12:10:00Z');
const snap = { stale_after_secs: 900, repos: [entry('a', {}), entry('b', {}, '2026-10-08T12:05:00Z')] };
assert.deepEqual(freshness(snap, now), { oldest: Date.parse('2026-10-08T12:00:00Z'), stale: false });
assert.equal(freshness(snap, now + 6 * 60_000).stale, true);
assert.equal(freshness({ ...snap, repos: [...snap.repos, entry('x', null, null)] }, now).oldest, null);
assert.equal(freshness({ stale_after_secs: 1, repos: [] }, now).stale, true);
});
test('formatting', () => {
const now = Date.parse('2026-10-08T12:00:00Z');
assert.equal(ago('2026-10-08T11:58:00Z', now), '2m');
assert.equal(ago('2026-10-06T12:00:00Z', now), '2d');
assert.equal(ago(null, now), '');
assert.equal(compact(279524), '280k');
assert.equal(compact(57247), '57.2k');
assert.equal(compact(950), '950');
});
test('totals and urgent merge', () => {
assert.equal(totals(rows).urgency, 110);
const e = [
entry('a', { urgent: [{ number: 1, score: 30, critical: false }, { number: 2, score: 80, critical: true }] }),
entry('b', { urgent: [{ number: 3, score: 50, critical: true }] }),
];
assert.deepEqual(urgentAcross(e).map((u) => u.number), [2, 3, 1]);
assert.deepEqual(urgentAcross(e, { criticalOnly: true }).map((u) => u.repo), ['o/a', 'o/b']);
assert.deepEqual(urgentAcross(e, { repo: 'o/b' }).map((u) => u.number), [3]);
});
+131
View File
@@ -0,0 +1,131 @@
:root {
color-scheme: light dark;
--bg: 246 248 251;
--surface: #eceff5;
--fg: #1a2233;
--muted: #687389;
--border: #d9dee8;
--accent: #2f6fe0;
--good: #188a5a;
--warn: #b7741a;
--bad: #d23c3c;
--hot-rgb: 210 60 60;
--row-hover: rgba(47, 111, 224, .06);
--opacity: .96;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: 11 18 32;
--surface: #141d31;
--fg: #dbe4f3;
--muted: #91a0bc;
--border: #22304c;
--accent: #5b9dff;
--good: #7cdbad;
--warn: #f4bc70;
--bad: #ff8f8f;
--hot-rgb: 255 110 110;
--row-hover: rgba(91, 157, 255, .07);
}
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; }
body { font: 12px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--fg); -webkit-font-smoothing: antialiased; }
button, select { color: inherit; font: inherit; cursor: pointer; }
button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
svg { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.app { height: 100%; display: flex; flex-direction: column; background: rgb(var(--bg) / var(--opacity)); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; position: relative; }
header { display: flex; align-items: center; gap: 14px; padding: 8px 10px 8px 15px; border-bottom: 1px solid var(--border); user-select: none; -webkit-user-select: none; }
.brand { display: flex; gap: 8px; align-items: center; color: var(--muted); font-weight: 600; white-space: nowrap; }
.brand svg { width: 15px; height: 15px; stroke-width: 2.2; color: var(--accent); }
.views { display: flex; gap: 2px; }
.view { border: 0; background: none; color: var(--muted); padding: 3px 9px; border-radius: 12px; white-space: nowrap; }
.view:hover { color: var(--fg); }
.view[aria-selected="true"] { background: var(--surface); color: var(--fg); }
.badge { font-variant-numeric: tabular-nums; opacity: .75; margin-left: 2px; }
.badge.hot:not(:empty) { color: var(--bad); opacity: 1; font-weight: 600; }
.status { flex: 1; display: flex; align-items: center; gap: 8px; min-width: 0; justify-content: flex-end; }
.refreshed { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
.pill { font-size: 10px; font-weight: 700; letter-spacing: .05em; padding: 1px 7px; border-radius: 9px; white-space: nowrap; }
.pill.fresh { color: var(--good); background: color-mix(in srgb, var(--good) 14%, transparent); }
.pill.stale { color: #fff; background: var(--bad); }
.pill.refreshing { color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); }
.pill.refreshing::before { content: ''; display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: currentColor; vertical-align: 1px; animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .2; } }
@media (prefers-reduced-motion: reduce) { .pill.refreshing::before { animation: none; } }
.actions { display: flex; gap: 2px; }
.icon-button { background: none; border: 0; border-radius: 6px; width: 26px; height: 26px; display: grid; place-items: center; color: var(--muted); padding: 0; }
.icon-button svg { width: 15px; height: 15px; }
.icon-button:hover:not(:disabled) { background: var(--surface); color: var(--fg); }
.icon-button[aria-pressed="true"] { color: var(--accent); }
.icon-button.spinning svg { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.banner { padding: 6px 15px; font-size: 11.5px; color: var(--bad); background: color-mix(in srgb, var(--bad) 10%, transparent); border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
main { flex: 1; min-height: 0; overflow: auto; }
/* ───── table ───── */
table { border-collapse: separate; border-spacing: 0; width: 100%; font-variant-numeric: tabular-nums; }
th, td { padding: 5px 8px; text-align: right; white-space: nowrap; border-bottom: 1px solid var(--border); }
th:first-child, td:first-child { text-align: left; position: sticky; left: 0; z-index: 1; background: rgb(var(--bg)); padding-left: 15px; }
thead th { position: sticky; background: rgb(var(--bg)); z-index: 2; }
thead tr.groups th { top: 0; color: var(--muted); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; text-align: center; border-bottom: 0; padding-bottom: 0; }
thead tr.groups th:not(:empty) { box-shadow: inset 0 -1px 0 var(--border); }
thead tr.cols th { top: 21px; color: var(--muted); font-weight: 600; cursor: pointer; user-select: none; -webkit-user-select: none; }
thead th:first-child { z-index: 3; }
thead tr.cols th:hover { color: var(--fg); }
thead tr.cols th.sorted { color: var(--accent); }
thead tr.cols th.sorted::after { content: ' ↓'; }
thead tr.cols th.sorted.asc::after { content: ' ↑'; }
th.group-start, td.group-start { border-left: 1px solid var(--border); }
tbody tr { cursor: pointer; }
tbody tr:hover td { background-color: var(--row-hover); }
tbody tr:hover td:first-child { background-image: linear-gradient(var(--row-hover), var(--row-hover)); }
.repo { font-weight: 600; display: inline-flex; align-items: center; gap: 6px; }
.repo .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--good); flex-shrink: 0; }
tr.stale .repo .dot { background: var(--warn); }
tr.error .repo .dot { background: var(--bad); }
tr.stale td:not(:first-child) { opacity: .55; }
.muted { color: var(--muted); }
.zero { color: var(--muted); opacity: .6; }
.active { color: var(--accent); font-weight: 600; }
.link:hover { text-decoration: underline; color: var(--accent); }
.urgency { display: inline-block; min-width: 40px; padding: 0 6px; border-radius: 4px; font-weight: 600; }
.crit { color: var(--bad); font-weight: 700; }
.pre { font-size: 9px; color: var(--warn); margin-left: 3px; vertical-align: 1px; }
.old { color: var(--warn); }
tfoot td { color: var(--muted); font-weight: 600; border-bottom: 0; position: sticky; bottom: 0; background: rgb(var(--bg)); box-shadow: inset 0 1px 0 var(--border); }
.empty { padding: 40px; text-align: center; color: var(--muted); }
/* ───── urgent view ───── */
.urgent-controls { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 12px; padding: 7px 15px; background: rgb(var(--bg)); border-bottom: 1px solid var(--border); }
.urgent-controls select { border: 1px solid var(--border); background: var(--surface); border-radius: 6px; padding: 2px 6px; }
.toggle { display: flex; gap: 5px; align-items: center; color: var(--muted); white-space: nowrap; }
.hint { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.urgent-list { list-style: none; margin: 0; padding: 0; }
.urgent-item { display: grid; grid-template-columns: 44px 92px 1fr auto; gap: 10px; align-items: baseline; padding: 7px 15px; border-bottom: 1px solid var(--border); cursor: pointer; }
.urgent-item:hover { background: var(--row-hover); }
.score { font-weight: 700; text-align: center; border-radius: 4px; font-variant-numeric: tabular-nums; }
.u-repo { color: var(--muted); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.u-title { min-width: 0; }
.u-title .num { color: var(--muted); margin-right: 5px; font-variant-numeric: tabular-nums; }
.u-title .text { font-weight: 500; }
.urgent-item.critical .u-title .text { color: var(--bad); }
.reasons { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
.reason { font-size: 10px; padding: 0 6px; border-radius: 8px; background: var(--surface); color: var(--muted); }
.reason.sev { color: var(--bad); background: color-mix(in srgb, var(--bad) 12%, transparent); }
.u-age { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
footer { display: flex; align-items: center; gap: 14px; padding: 6px 15px; border-top: 1px solid var(--border); color: var(--muted); font-size: 10.5px; user-select: none; -webkit-user-select: none; font-variant-numeric: tabular-nums; }
.spacer { flex: 1; }
.footer-link { padding: 0; border: 0; background: none; color: var(--muted); font-size: 10.5px; }
.footer-link:hover { color: var(--fg); text-decoration: underline; }
#resize { position: absolute; bottom: 0; right: 0; width: 14px; height: 14px; cursor: nwse-resize; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { border-radius: 6px; background: var(--border); }
::-webkit-scrollbar-corner { background: transparent; }