Merge pull request #6 from storytold/bt-typescript

Port the UI to TypeScript and ban raw JavaScript
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-12 23:56:20 -04:00
commit 0022e899bc
22 files changed
+1976 -714

No files matched your search

+1
View File
@@ -5,3 +5,4 @@
.DS_Store
node_modules/
ui/dev-snapshot.json
ui/build/
+29
View File
@@ -0,0 +1,29 @@
# Craft Repo Status App
## Rules
- **No raw JavaScript, ever.** All frontend code is TypeScript in `ui/src/`.
Never add `.js`, `.mjs`, `.cjs` or `.jsx` files, inline `<script>` code,
inline `on…=` handlers or `javascript:` URLs, and never commit `ui/build/`
(tsc's output). Scripts and tooling go in Rust, shell or Makefile targets,
not JS. `ui/src/repo.test.ts` enforces this in `make test`.
- New UI code gets tests: pure logic in `ui/src/model.ts` with
`model.test.ts`, DOM behavior in `app.test.ts` (happy-dom, fake host API).
## TypeScript setup
- `tsc` only, no bundler. Sources import each other with `.ts` extensions;
`node --test` runs them directly (type stripping) and tsc rewrites the
imports to `.js` when emitting. Use only erasable syntax
(`erasableSyntaxOnly`): no enums, namespaces or parameter properties.
- `tsconfig.json` type-checks everything, tests included; `tsconfig.build.json`
emits the browser code to `ui/build/`. `crates/craft-status-app/build.rs`
runs `npm run build` before Tauri embeds `ui/`.
- `ui/src/model.ts`'s data types mirror `crates/craft-core/src/model.rs`:
change both together.
## Commands
- `make test`: Rust tests + `npm test` (UI tests)
- `make check`: clippy, rustfmt, `npm run typecheck`
- `make preview`: the UI in a browser against `ui/dev-snapshot.json`
+18 -10
View File
@@ -6,18 +6,18 @@ 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
.PHONY: dev run build bundle install uninstall test check ui snapshot preview icons clean
dev: ## tauri dev (live-reloads ui/)
cd $(APP_DIR) && cargo tauri dev
dev: node_modules ## tauri dev, recompiling ui/src/ on change (live-reloads ui/)
npm run --silent watch & trap 'kill $$!' EXIT; cd $(APP_DIR) && cargo tauri dev
run: ## fast debug binary, no bundling
run: node_modules ## fast debug binary, no bundling
cargo run -p craft-status-app
build: ## release build + every installer for this OS
build: node_modules ## release build + every installer for this OS
cd $(APP_DIR) && cargo tauri build
bundle: ## release .app only (macOS)
bundle: node_modules ## release .app only (macOS)
cd $(APP_DIR) && cargo tauri build --bundles app
install: bundle ## copy to /Applications, start at login, launch now (macOS)
@@ -37,18 +37,19 @@ 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
test: node_modules ## Rust + UI tests
cargo test --workspace
node --test ui/*.test.mjs
npm test
check: ## lints
check: node_modules ## lints
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all -- --check
npm run --silent typecheck
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
preview: snapshot ui ## 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
@@ -58,3 +59,10 @@ icons: ## regenerate icons from the SVG sources
clean:
cargo clean
node_modules: package.json package-lock.json
npm ci
@touch node_modules
ui: node_modules ## compile ui/src/*.ts to ui/build/ (build.rs does this for app builds)
npm run --silent build
+11 -5
View File
@@ -15,13 +15,18 @@ cache, and tells you what to fix first. Everything runs locally: no server.
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`.
- **TypeScript, HTML and CSS** in `ui/`, with no framework or bundler. `tsc`
compiles `ui/src/` to `ui/build/`, which `index.html` loads; the app's
`build.rs` runs it, so building the app needs Node.js 24+ and `npm ci`.
`ui/src/model.ts` is pure; it and the UI itself (in happy-dom) are tested
with `node --test`. No JavaScript is committed: see `CLAUDE.md`.
## What it shows
Every column sorts. Click a header; click it again to reverse the order. Repos
with no data always sort last.
with no data always sort last. Or drag rows into your own order (say, by
priority): dropping one clears the sort, and the order is remembered until you
sort by a column again.
| Group | Columns |
| --- | --- |
@@ -140,6 +145,7 @@ repo list or the intervals take effect without a restart.
Prerequisites:
- Rust stable
- Node.js 24+, then `npm ci` (the UI's TypeScript compiler and test DOM)
- `cargo install tauri-cli --version "^2"`
- Xcode CLT on macOS
- `gh auth login`, or a token in the config
@@ -148,8 +154,8 @@ Prerequisites:
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 test # Rust + UI tests
make check # clippy + rustfmt + tsc type-check
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)
```
+1 -1
View File
@@ -11,7 +11,7 @@ use crate::BuildLag;
pub type Time = DateTime<Utc>;
/// The "recent activity" windows, as `(id, minutes)`, shortest first. Every
/// poll fetches all of them; the UI's dropdown picks one by id (`ui/model.js`
/// poll fetches all of them; the UI's dropdown picks one by id (`ui/src/model.ts`
/// lists the same ids).
pub const WINDOWS: &[(&str, i64)] = &[
("10m", 10),
+34
View File
@@ -1,3 +1,37 @@
use std::path::Path;
use std::process::Command;
fn main() {
build_ui();
tauri_build::build()
}
/// Compiles the TypeScript UI (ui/src/) to ui/build/ before Tauri embeds
/// ui/, so every cargo build of the app ships the current UI.
fn build_ui() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
for path in [
"ui/src",
"tsconfig.json",
"tsconfig.build.json",
"package.json",
"package-lock.json",
] {
println!("cargo:rerun-if-changed={}", root.join(path).display());
}
if !root.join("node_modules/.bin/tsc").exists() {
panic!(
"TypeScript isn't installed: run `npm ci` in {}",
root.display()
);
}
let status = Command::new("npm")
.args(["run", "--silent", "build"])
.current_dir(&root)
.status()
.expect("couldn't run npm; the UI build needs Node.js 24+");
assert!(
status.success(),
"the UI's TypeScript failed to compile (npm run build)"
);
}
+504
View File
@@ -0,0 +1,504 @@
{
"name": "craft-status-ui",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "craft-status-ui",
"devDependencies": {
"@types/node": "24.19.0",
"happy-dom": "20.14.5",
"typescript": "7.0.2"
},
"engines": {
"node": ">=24"
}
},
"node_modules/@types/node": {
"version": "24.19.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz",
"integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/buffer-image-size": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/happy-dom": {
"version": "20.14.5",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.5.tgz",
"integrity": "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"buffer-image-size": "^0.6.4",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.21.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "craft-status-ui",
"private": true,
"type": "module",
"engines": {
"node": ">=24"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"watch": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test \"ui/src/**/*.test.ts\""
},
"devDependencies": {
"@types/node": "24.19.0",
"happy-dom": "20.14.5",
"typescript": "7.0.2"
}
}
+11
View File
@@ -0,0 +1,11 @@
// The browser code, without tests, compiled to ui/build/ (which index.html
// loads). crates/craft-status-app/build.rs runs this before every app build.
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": [],
"noEmit": false,
"outDir": "ui/build"
},
"exclude": ["ui/src/**/*.test.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
// Type-checks the UI and its tests. Sources import each other as `./x.ts`, so
// `node --test` runs them directly (type stripping) and tsc rewrites the
// imports to `./x.js` when building; `erasableSyntaxOnly` keeps to syntax
// Node can strip. tsconfig.build.json emits the browser code to ui/build/.
{
"compilerOptions": {
"target": "es2023",
"module": "esnext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"lib": ["es2023", "dom", "dom.iterable"],
"types": ["node"],
"strict": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"rewriteRelativeImportExtensions": true,
"allowJs": false,
"skipLibCheck": true,
"rootDir": "ui/src",
"noEmit": true
},
"include": ["ui/src"]
}
-408
View File
@@ -1,408 +0,0 @@
// 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, RECENT, WINDOWS, windowById, value, sortEntries, MANUAL, manualOrder, 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' }),
order: load('order', []),
window: windowById(load('window', '')).id,
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);
/** The repo's app icon; repos without one (libraries) get a lettered tile, unfetched ones a blank. */
function appIcon(entry) {
if (entry.icon) return el('img', { class: 'app-icon', src: entry.icon, alt: '' });
if (!entry.stats) return el('span', { class: 'app-icon pending', 'aria-hidden': 'true' });
return el('span', { class: 'app-icon none', 'aria-hidden': 'true' }, entry.repo.split('/').pop().charAt(0).toUpperCase());
}
/** Cell content for a column. */
function cell(entry, col) {
const v = value(entry, col.key, state.window);
const s = entry.stats;
if (col.key === 'name') {
const tip = entry.error
? `Last attempt failed: ${entry.error}`
: entry.inaccessible ? 'Not accessible (private, or not found)'
: entry.fetched_at ? `Fetched ${localTime(entry.fetched_at)}` : 'Not fetched yet';
return el('span', { class: 'repo', title: tip }, el('span', { class: 'dot' }), appIcon(entry), link(v, gh(entry.repo)));
}
if (!s) return entry.inaccessible ? '' : 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 'recent_commits': case 'recent_prs': case 'recent_merged': case 'recent_issues': case 'recent_people':
if (v === null) {
const why = state.window === 'release' && !s.latest_build ? 'No release yet' : 'Not fetched yet for this window';
return el('span', { class: 'muted', title: why }, '—');
}
return el('span', {
class: v ? 'active' : 'zero',
title: state.window === 'release' ? `Since ${s.latest_build}, published ${localTime(s.latest_build_at)} (${ago(s.latest_build_at)} ago)` : null,
}, num(v));
case 'commits_since_build': {
const lag = s.since_build;
if (!lag) return el('span', { class: 'muted' }, '—');
const short = (sha) => sha?.slice(0, 7);
const tip = {
exact: `${num(v)} commits on main since ${s.latest_build} (${short(lag.from_sha)})`,
branched: `${s.latest_build}’s commit isn’t on main: ~${num(v)} commits on main since the two split`,
by_date: `${s.latest_build}’s commit shares no history with main: ~${num(v)} commits on main are newer than it`
+ (lag.from_sha ? `, counted from ${short(lag.from_sha)}` : ''),
}[lag.basis];
const from = lag.basis === 'by_date' ? lag.from_sha : s.latest_build;
const text = `${lag.basis === 'exact' ? '' : '~'}${num(v)}`;
return el('span', { title: tip }, from ? link(text, gh(entry.repo, `/compare/${encodeURIComponent(from)}...HEAD`), v ? '' : 'zero') : text);
}
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 ─────────────────────────────
/** The group row, built once: rebuilding it would destroy the window
* dropdown, closing it mid-choice whenever a poll or the clock re-renders. */
let groupRow = null;
function renderHead() {
if (!groupRow) {
groupRow = el('tr', { class: 'groups' });
let prev = null;
for (const c of COLUMNS) {
if (c.group === prev) { groupRow.lastChild.colSpan += 1; continue; }
prev = c.group;
groupRow.append(el('th', { class: c.group ? 'group-start' : '' }, c.group === RECENT ? windowPicker() : c.group));
}
$('thead').replaceChildren(groupRow);
}
const picker = groupRow.querySelector('.window-picker');
if (picker.value !== state.window) picker.value = state.window;
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?.replace('{span}', windowById(state.window).span) ?? `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));
});
groupRow.nextSibling?.remove();
$('thead').append(cols);
}
/** The dropdown that heads the recent-activity columns. */
function windowPicker() {
const sel = el('select', {
class: 'window-picker',
'aria-label': 'Time window for recent activity',
title: 'Time window for the activity columns',
onchange: (e) => { state.window = e.target.value; save('window', state.window); render(); },
}, WINDOWS.map((w) => el('option', { value: w.id }, w.label)));
return sel;
}
function groupStart(i) {
return i > 0 && COLUMNS[i - 1].group !== COLUMNS[i].group ? 'group-start' : '';
}
function renderTable() {
renderHead();
const snap = state.snap;
const rows = state.sort.key === MANUAL
? manualOrder(snap.repos, state.order)
: sortEntries(snap.repos, state.sort.key, state.sort.dir, state.window);
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(' '),
dataset: { repo: e.repo },
title: 'Click for this repo’s most urgent issues · drag to reorder',
onpointerdown: startRowDrag,
onclick: () => {
if (justDragged) return;
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, state.window);
$('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])) : ''))));
}
// ───────────────────────────── row dragging ─────────────────────────────
// Pointer events rather than HTML5 drag and drop, which the webview's own
// file-drop handling can swallow. A press turns into a drag once it moves a
// few pixels; until then it is an ordinary click. Dropping saves the order on
// screen and switches the table to it; clicking a column sorts again.
let drag = null;
let justDragged = false;
function startRowDrag(e) {
if (e.button !== 0 || e.target.closest('.link')) return;
drag = { row: e.currentTarget, startY: e.clientY, moved: false };
window.addEventListener('pointermove', moveRowDrag);
window.addEventListener('pointerup', endRowDrag);
window.addEventListener('pointercancel', cancelRowDrag);
}
function moveRowDrag(e) {
if (!drag.moved) {
if (Math.abs(e.clientY - drag.startY) < 5) return;
drag.moved = true;
drag.row.classList.add('dragging');
document.body.classList.add('reordering');
window.getSelection()?.removeAllRanges();
}
const main = $('repos-view');
const box = main.getBoundingClientRect();
if (e.clientY < box.top + 50) main.scrollTop -= 12;
else if (e.clientY > box.bottom - 40) main.scrollTop += 12;
const tbody = $('tbody');
const next = [...tbody.children].find((r) => {
if (r === drag.row) return false;
const b = r.getBoundingClientRect();
return e.clientY < b.top + b.height / 2;
}) ?? null;
if (drag.row.nextSibling !== next) tbody.insertBefore(drag.row, next);
}
function finishRowDrag() {
window.removeEventListener('pointermove', moveRowDrag);
window.removeEventListener('pointerup', endRowDrag);
window.removeEventListener('pointercancel', cancelRowDrag);
document.body.classList.remove('reordering');
const moved = drag.moved;
drag = null;
return moved;
}
function endRowDrag() {
if (!finishRowDrag()) return;
// The click that follows this pointerup must not open the urgent view.
justDragged = true;
setTimeout(() => { justDragged = false; });
state.order = [...$('tbody').children].map((r) => r.dataset.repo);
state.sort = { key: MANUAL };
save('order', state.order);
save('sort', state.sort);
render();
}
function cancelRowDrag() {
if (finishRowDrag()) render();
}
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' }, appIcon(snap.repos.find((r) => r.repo === u.repo) ?? { repo: 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() {
// Rebuilding the table mid-drag would drop the row out of the user's hand;
// the drop renders anyway.
if (!state.snap || drag?.moved) 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');
}
})();
+3 -7
View File
@@ -61,12 +61,8 @@
</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>
<!-- Compiled from ui/src/ by `npm run build` (run by the app's build.rs). -->
<script type="module" src="build/report.js"></script>
<script type="module" src="build/main.js"></script>
</body>
</html>
-179
View File
@@ -1,179 +0,0 @@
// 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 */
/**
* The activity windows the dropdown offers, shortest first, then since each
* repo's latest release (so a different span per repo; repos without a release
* have no data), then all time. The ids match `WINDOWS`, `SINCE_RELEASE` and
* `ALL_TIME` in crates/craft-core/src/model.rs; every poll fetches all of them.
*/
export const WINDOWS = [
{ id: '10m', label: 'Last 10 min', span: 'in the last 10 minutes' },
{ id: '30m', label: 'Last 30 min', span: 'in the last 30 minutes' },
{ id: '1h', label: 'Last hour', span: 'in the last hour' },
{ id: '4h', label: 'Last 4 h', span: 'in the last 4 hours' },
{ id: '12h', label: 'Last 12 h', span: 'in the last 12 hours' },
{ id: '1d', label: 'Last day', span: 'in the last day' },
{ id: '7d', label: 'Last week', span: 'in the last week' },
{ id: 'release', label: 'Since last release', span: 'since each repo’s latest release' },
{ id: 'all', label: 'All time', span: 'over all time' },
];
export const DEFAULT_WINDOW = '4h';
export const windowById = (id) => WINDOWS.find((w) => w.id === id) ?? windowById(DEFAULT_WINDOW);
/**
* Table columns, in display order. `get` reads from a RepoEntry; numbers and
* dates sort descending first (biggest / newest on top), text ascending.
* Columns with `recent` read that field of the selected window's activity;
* their group is `RECENT`, which the table draws as the window dropdown, and
* `{span}` in their titles names the window.
*/
export const RECENT = 'recent';
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: 'recent_commits', recent: 'commits', label: 'Commits', kind: 'num', group: RECENT, title: 'Commits to main {span}' },
{ key: 'recent_prs', recent: 'prs_opened', label: 'PRs', kind: 'num', group: RECENT, title: 'Pull requests opened {span}' },
{ key: 'recent_merged', recent: 'prs_merged', label: 'Merged', kind: 'num', group: RECENT, title: 'Pull requests merged {span}' },
{ key: 'recent_issues', recent: 'issues_opened', label: 'Issues', kind: 'num', group: RECENT, title: 'Issues opened {span}' },
{ key: 'recent_people', recent: 'people', label: 'People', kind: 'num', group: RECENT, title: 'People who committed to main {span} (all time: GitHub’s contributor count)' },
{ 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: 'commits_since_build', label: 'Unreleased', kind: 'num', group: 'Builds', title: 'Commits on main since the most recent release (~ means estimated: the release’s commit isn’t on main)', get: (e) => e.stats?.since_build?.commits ?? null },
{ 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
* (including a cache written before that window existed).
*/
export function value(entry, key, window = DEFAULT_WINDOW) {
const col = COLUMN[key];
if (col?.get) return col.get(entry);
if (col?.recent) return entry.stats?.recent?.[window]?.[col.recent] ?? null;
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, window = DEFAULT_WINDOW) {
const col = COLUMN[key] ?? COLUMN.urgency;
const sign = dir === 'asc' ? 1 : -1;
return [...entries].sort((a, b) => {
const va = value(a, col.key, window);
const vb = value(b, col.key, window);
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'));
});
}
/** The `sort.key` of a hand-arranged table: rows follow a saved list of repos. */
export const MANUAL = 'manual';
/** Entries in the order of `order` (repo names); repos missing from it keep config order, after the rest. */
export function manualOrder(entries, order) {
const rank = new Map(order.map((repo, i) => [repo, i]));
const key = (e, i) => rank.get(e.repo) ?? order.length + i;
return entries.map((e, i) => [key(e, i), e]).sort((a, b) => a[0] - b[0]).map(([, e]) => e);
}
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, window = DEFAULT_WINDOW) {
const out = {};
for (const c of COLUMNS) {
if (c.kind !== 'num') continue;
out[c.key] = entries.reduce((sum, e) => sum + (value(e, c.key, window) ?? 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);
}
-104
View File
@@ -1,104 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sortEntries, manualOrder, versionKey, freshness, ago, compact, totals, urgentAcross, defaultDir, value, windowById, WINDOWS } 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('commits since the build read through to since_build', () => {
const lagged = [
entry('a', { since_build: { commits: 3, basis: 'exact', from_sha: 'x' } }),
entry('b', { since_build: { commits: 40, basis: 'branched', from_sha: null } }),
entry('c', { since_build: null }),
entry('d', null, null),
];
assert.equal(names(sortEntries(lagged, 'commits_since_build', 'desc')), 'bacd');
assert.equal(value(lagged[2], 'commits_since_build'), null);
assert.equal(totals(lagged).commits_since_build, 43);
});
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]);
});
test('recent-activity columns follow the selected window', () => {
const act = (commits, prs_merged, people = 0) => ({ commits, prs_opened: 0, prs_merged, issues_opened: 0, people });
const e = [
entry('a', { recent: { '4h': act(5, 1, 2), '7d': act(9, 40), all: act(1234, 300, 12) } }),
entry('b', { recent: { '4h': act(8, 2), '7d': act(8, 10) } }),
entry('old', { commits_4h: 99 }),
];
assert.equal(value(e[0], 'recent_commits'), 5, 'defaults to 4 h');
assert.equal(value(e[0], 'recent_merged', '7d'), 40);
assert.equal(value(e[0], 'recent_commits', '10m'), null, 'window not fetched yet');
assert.equal(value(e[2], 'recent_commits'), null, 'cache from before windows');
assert.equal(names(sortEntries(e, 'recent_commits', 'desc', '4h')), 'baold');
assert.equal(names(sortEntries(e, 'recent_commits', 'desc', '7d')), 'abold');
assert.equal(totals(e, '7d').recent_merged, 50);
assert.equal(windowById('bogus').id, '4h');
assert.equal(value(e[0], 'recent_people'), 2);
assert.equal(value(e[0], 'recent_people', 'all'), 12, 'all time is just another window');
assert.equal(value(e[1], 'recent_commits', 'all'), null, 'cache from before all time');
assert.equal(names(sortEntries(e, 'recent_merged', 'desc', 'all')), 'abold');
assert.equal(value(e[0], 'recent_commits', 'release'), null, 'no release');
assert.deepEqual(WINDOWS.map((w) => w.id), ['10m', '30m', '1h', '4h', '12h', '1d', '7d', 'release', 'all']);
});
test('manual order follows the saved list, new repos last in config order', () => {
assert.equal(names(manualOrder(rows, ['o/d', 'o/b'])), 'dbac');
assert.equal(names(manualOrder(rows, ['o/gone', 'o/c'])), 'cbad');
assert.equal(names(manualOrder(rows, [])), 'bacd');
});
+298
View File
@@ -0,0 +1,298 @@
// The UI end to end in happy-dom: index.html's markup, the real `start`, and a
// fake host API standing in for Tauri.
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { Window } from 'happy-dom';
import { start } from './app.ts';
import type { Api } from './app.ts';
import type { Meta, RepoEntry, RepoStats, Snapshot, UrgentIssue } from './model.ts';
const HTML = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const BODY = HTML.match(/<body>([\s\S]*)<\/body>/)![1]!.replace(/<script[\s\S]*?<\/script>/g, '');
const NOW = new Date().toISOString();
const ROW_HEIGHT = 20;
const issue = (number: number, score: number, critical: boolean): UrgentIssue => ({
number, score, critical, title: `Issue ${number}`, url: `https://github.com/o/x/issues/${number}`,
created_at: NOW, updated_at: NOW, comments: 0, reactions: 0, labels: [], reasons: critical ? ['crash'] : [],
});
const stats = (s: Partial<RepoStats>): RepoStats => ({
open_prs: 1, open_issues: 2, last_commit_at: NOW, last_issue_at: NOW, oldest_open_pr_at: NOW, newest_pr_at: NOW,
recent: {}, latest_build: 'v1.0.0', latest_build_at: NOW, latest_build_prerelease: false, latest_build_sha: null,
since_build: null, latest_build_downloads: 0, downloads_total: 0, urgency: 0, critical_issues: 0, urgent: [], icon_oid: null,
...s,
});
const repo = (name: string, s: Partial<RepoStats> | null, extra: Partial<RepoEntry> = {}): RepoEntry =>
({ repo: `o/${name}`, stats: s && stats(s), fetched_at: NOW, ...extra });
const snapshot = (repos: RepoEntry[] = [
repo('alpha', { urgency: 100, critical_issues: 1, urgent: [issue(1, 80, true), issue(2, 20, false)] }),
repo('beta', { urgency: 300, urgent: [issue(3, 50, false)] }),
repo('gamma', { urgency: 200 }),
]): Snapshot => ({
repos, last_full_refresh_at: NOW, refreshing: false, next_refresh_at: NOW, poll_secs: 300,
stale_after_secs: 900, rate_limit_remaining: 4000, error: null,
});
interface Harness {
win: Window;
doc: Document;
calls: { cmd: string; args?: Record<string, unknown> }[];
emit<T>(event: string, payload: T): void;
rows(): string[];
row(name: string): HTMLTableRowElement;
view(): string;
}
let teardown: (() => Promise<void>) | null = null;
afterEach(async () => { await teardown?.(); teardown = null; });
const tick = () => new Promise((r) => setTimeout(r, 0));
/** A fresh document with the UI started in it. */
async function setup({ snap = snapshot(), storage = {} as Record<string, unknown>, meta = {} as Meta } = {}): Promise<Harness> {
const win = new Window({ url: 'http://localhost/index.html' });
win.document.body.innerHTML = BODY;
for (const [k, v] of Object.entries(storage)) win.localStorage.setItem(k, JSON.stringify(v));
const globals = { window: win, document: win.document, localStorage: win.localStorage, location: win.location, navigator: win.navigator };
const saved = Object.fromEntries(Object.keys(globals).map((k) => [k, Object.getOwnPropertyDescriptor(globalThis, k)]));
for (const [k, v] of Object.entries(globals)) Object.defineProperty(globalThis, k, { value: v, configurable: true, writable: true });
// Layout happy-dom doesn't do: rows stacked by DOM position, a tall table.
(win.HTMLTableRowElement.prototype as unknown as HTMLTableRowElement).getBoundingClientRect = function (this: HTMLTableRowElement) {
const i = [...(this.parentElement?.children ?? [])].indexOf(this);
return { top: i * ROW_HEIGHT, bottom: (i + 1) * ROW_HEIGHT, height: ROW_HEIGHT, left: 0, right: 0, width: 0, x: 0, y: i * ROW_HEIGHT } as DOMRect;
};
const doc = win.document as unknown as Document;
doc.getElementById('repos-view')!.getBoundingClientRect = () => ({ top: -1000, bottom: 10000 }) as DOMRect;
const calls: Harness['calls'] = [];
const listeners = new Map<string, (p: unknown) => void>();
const api: Api = {
async invoke<T>(cmd: string, args?: Record<string, unknown>) {
calls.push({ cmd, args });
if (cmd === 'get_snapshot') return snap as T;
if (cmd === 'get_meta') return meta as T;
return null as T;
},
listen: (ev, fn) => { listeners.set(ev, fn as (p: unknown) => void); },
startResize: () => { calls.push({ cmd: 'startResize' }); },
};
const stop = start(api);
await tick();
teardown = async () => {
stop();
await win.happyDOM.close();
for (const [k, d] of Object.entries(saved)) {
if (d) Object.defineProperty(globalThis, k, d); else delete (globalThis as Record<string, unknown>)[k];
}
};
const tbody = () => doc.getElementById('tbody')!;
return {
win, doc, calls,
emit: (event, payload) => listeners.get(event)?.(payload),
rows: () => [...tbody().querySelectorAll('tr')].map((r) => r.dataset.repo?.slice(2) ?? ''),
row: (name) => tbody().querySelector<HTMLTableRowElement>(`tr[data-repo="o/${name}"]`)!,
view: () => (doc.getElementById('repos-view')!.hidden ? 'urgent' : 'repos'),
};
}
const sortedHeader = (h: Harness) => h.doc.querySelector('thead tr.cols th.sorted')?.textContent ?? null;
const header = (h: Harness, label: string) =>
[...h.doc.querySelectorAll<HTMLElement>('thead tr.cols th')].find((th) => th.textContent === label)!;
const stored = (h: Harness, k: string): unknown => JSON.parse(h.win.localStorage.getItem(k) ?? 'null');
function pointer(h: Harness, target: EventTarget, type: string, clientY: number): void {
target.dispatchEvent(new h.win.PointerEvent(type, { bubbles: true, button: 0, clientY }) as unknown as Event);
}
/** Presses on `name`'s row and moves the pointer to `toY` in small steps; `drop` releases it. */
function dragRow(h: Harness, name: string, toY: number, { drop = true } = {}): void {
const row = h.row(name);
const y0 = row.getBoundingClientRect().top + ROW_HEIGHT / 2;
pointer(h, row.cells[3]!, 'pointerdown', y0);
const step = toY > y0 ? 4 : -4;
for (let y = y0; step > 0 ? y < toY : y > toY; y += step) pointer(h, h.win as unknown as EventTarget, 'pointermove', y);
pointer(h, h.win as unknown as EventTarget, 'pointermove', toY);
if (drop) {
pointer(h, h.win as unknown as EventTarget, 'pointerup', toY);
row.click();
}
}
// ───────────────────────────── table ─────────────────────────────
test('renders a row per repo, sorted by urgency by default', async () => {
const h = await setup();
assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha']);
assert.equal(sortedHeader(h), 'Urgency');
assert.equal(h.doc.querySelector('tfoot td')!.textContent, '3 repos');
assert.equal(h.doc.getElementById('repo-count')!.textContent, '3');
assert.equal(h.doc.getElementById('urgent-count')!.textContent, '1');
assert.ok(h.calls.some((c) => c.cmd === 'window_ready'));
});
test('clicking a header sorts by it, then flips it, and remembers', async () => {
const h = await setup();
header(h, 'Repo').click();
assert.deepEqual(h.rows(), ['alpha', 'beta', 'gamma']);
header(h, 'Repo').click();
assert.deepEqual(h.rows(), ['gamma', 'beta', 'alpha']);
assert.ok(h.doc.querySelector('th.sorted:not(.asc)'));
assert.deepEqual(stored(h, 'sort'), { key: 'name', dir: 'desc' });
});
test('dragging a row reorders the table and switches it to manual order', async () => {
const h = await setup();
dragRow(h, 'beta', 2.5 * ROW_HEIGHT);
assert.deepEqual(h.rows(), ['gamma', 'alpha', 'beta']);
assert.equal(sortedHeader(h), null, 'no column is sorted');
assert.deepEqual(stored(h, 'sort'), { key: 'manual' });
assert.deepEqual(stored(h, 'order'), ['o/gamma', 'o/alpha', 'o/beta']);
assert.equal(h.view(), 'repos', 'the click ending a drag does not open the urgent view');
assert.ok(!h.doc.body.classList.contains('reordering'));
});
test('dragging upward and to the top works', async () => {
const h = await setup();
dragRow(h, 'alpha', 0);
assert.deepEqual(h.rows(), ['alpha', 'beta', 'gamma']);
});
test('sorting after a drag goes back to the sorted view', async () => {
const h = await setup();
dragRow(h, 'beta', 2.5 * ROW_HEIGHT);
header(h, 'Urgency').click();
assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha']);
assert.equal(sortedHeader(h), 'Urgency');
assert.deepEqual(stored(h, 'order'), ['o/gamma', 'o/alpha', 'o/beta'], 'the manual order is kept');
});
test('a saved manual order is restored, with new repos last', async () => {
const h = await setup({ storage: { sort: { key: 'manual' }, order: ['o/gamma', 'o/alpha'] } });
assert.deepEqual(h.rows(), ['gamma', 'alpha', 'beta']);
assert.equal(sortedHeader(h), null);
});
test('a poll arriving mid-drag waits for the drop', async () => {
const h = await setup();
dragRow(h, 'beta', 1.6 * ROW_HEIGHT, { drop: false });
assert.deepEqual(h.rows(), ['gamma', 'beta', 'alpha']);
h.emit('craft:snapshot', snapshot());
assert.ok(h.row('beta').classList.contains('dragging'), 'the dragged row is still in hand');
assert.deepEqual(h.rows(), ['gamma', 'beta', 'alpha']);
pointer(h, h.win as unknown as EventTarget, 'pointerup', 1.6 * ROW_HEIGHT);
assert.deepEqual(h.rows(), ['gamma', 'beta', 'alpha']);
assert.ok(!h.doc.querySelector('tr.dragging'));
});
test('a cancelled drag restores the previous order', async () => {
const h = await setup();
dragRow(h, 'beta', 2.5 * ROW_HEIGHT, { drop: false });
pointer(h, h.win as unknown as EventTarget, 'pointercancel', 0);
assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha']);
assert.equal(stored(h, 'order'), null);
});
test('clicking a row, or barely moving it, opens its urgent issues', async () => {
const h = await setup();
const row = h.row('alpha');
const y = row.getBoundingClientRect().top + 5;
pointer(h, row, 'pointerdown', y);
pointer(h, h.win as unknown as EventTarget, 'pointermove', y + 3);
pointer(h, h.win as unknown as EventTarget, 'pointerup', y + 3);
row.click();
assert.equal(h.view(), 'urgent');
assert.equal(h.doc.querySelector<HTMLSelectElement>('#urgent-repo')!.value, 'o/alpha');
assert.deepEqual([...h.doc.querySelectorAll('.urgent-item .num')].map((n) => n.textContent), ['#1', '#2']);
assert.equal(stored(h, 'order'), null, 'a click is not a reorder');
});
test('links in a row open on GitHub without dragging or switching views', async () => {
const h = await setup();
const link = h.row('gamma').querySelector<HTMLElement>('.repo .link')!;
pointer(h, link, 'pointerdown', 25);
pointer(h, h.win as unknown as EventTarget, 'pointermove', 60);
link.click();
assert.deepEqual(h.rows(), ['beta', 'gamma', 'alpha']);
assert.equal(h.view(), 'repos');
assert.deepEqual(h.calls.filter((c) => c.cmd === 'open_url').map((c) => c.args?.url), ['https://github.com/o/gamma']);
});
test('the window picker switches the activity columns and remembers', async () => {
const h = await setup({ snap: snapshot([repo('alpha', { recent: { '7d': { commits: 7, prs_opened: 0, prs_merged: 0, issues_opened: 0, people: 0 } } })]) });
const commits = () => h.row('alpha').cells[9]!.textContent;
assert.equal(commits(), '—', 'the default 4 h window has no data');
const picker = h.doc.querySelector<HTMLSelectElement>('.window-picker')!;
picker.value = '7d';
picker.dispatchEvent(new h.win.Event('change') as unknown as Event);
assert.equal(commits(), '7');
assert.equal(stored(h, 'window'), '7d');
});
test('an empty config says so', async () => {
const h = await setup({ snap: snapshot([]) });
assert.match(h.doc.getElementById('tbody')!.textContent ?? '', /No repositories configured/);
});
// ───────────────────────────── status ─────────────────────────────
test('status pill, failures banner and budget', async () => {
const h = await setup({ snap: snapshot([repo('alpha', {}), repo('beta', null, { error: 'boom', fetched_at: null })]) });
assert.equal(h.doc.getElementById('fresh-pill')!.textContent, 'STALE');
const banner = h.doc.getElementById('banner')!;
assert.equal(banner.hidden, false);
assert.match(banner.textContent ?? '', /1 repo failed last poll: beta \(boom\)/);
assert.equal(h.doc.getElementById('budget')!.textContent, 'GitHub budget 4,000');
assert.ok(h.row('beta').classList.contains('error'));
h.emit('craft:snapshot', { ...snapshot(), refreshing: true });
assert.equal(h.doc.getElementById('fresh-pill')!.textContent, 'REFRESHING');
assert.equal(banner.hidden, true);
h.emit('craft:snapshot', snapshot());
assert.equal(h.doc.getElementById('fresh-pill')!.textContent, 'FRESH');
});
// ───────────────────────────── urgent view ─────────────────────────────
test('urgent view lists every repo’s issues, filters to critical, and opens them', async () => {
const h = await setup();
h.doc.querySelector<HTMLElement>('[data-view="urgent"]')!.click();
const nums = () => [...h.doc.querySelectorAll('.urgent-item .num')].map((n) => n.textContent);
assert.deepEqual(nums(), ['#1', '#3', '#2']);
const only = h.doc.querySelector<HTMLInputElement>('#critical-only')!;
only.checked = true;
only.dispatchEvent(new h.win.Event('change') as unknown as Event);
assert.deepEqual(nums(), ['#1']);
assert.equal(stored(h, 'criticalOnly'), true);
h.doc.querySelector<HTMLElement>('.urgent-item')!.click();
assert.equal(h.calls.at(-1)?.args?.url, 'https://github.com/o/x/issues/1');
});
// ───────────────────────────── host ─────────────────────────────
test('keyboard shortcuts and buttons call the host', async () => {
const h = await setup({ meta: { always_on_top: true, shortcut: 'CmdOrCtrl+Shift+G' } });
const key = (key: string, mod = false) =>
h.doc.dispatchEvent(new h.win.KeyboardEvent('keydown', { key, metaKey: mod, bubbles: true }) as unknown as Event);
key('r', true);
key('Escape');
key('2', true);
assert.equal(h.view(), 'urgent');
key('1', true);
assert.equal(h.view(), 'repos');
h.doc.getElementById('pin')!.click();
h.doc.getElementById('edit-config')!.click();
h.doc.getElementById('resize')!.dispatchEvent(new h.win.MouseEvent('mousedown') as unknown as Event);
const cmds = h.calls.map((c) => c.cmd).filter((c) => !['get_meta', 'get_snapshot', 'window_ready'].includes(c));
assert.deepEqual(cmds, ['refresh_now', 'hide_window', 'set_always_on_top', 'open_config', 'startResize']);
assert.deepEqual(h.calls.find((c) => c.cmd === 'set_always_on_top')?.args, { on: false });
assert.equal(h.doc.getElementById('pin')!.getAttribute('aria-pressed'), 'true');
});
+429
View File
@@ -0,0 +1,429 @@
// Craft Status UI: renders the snapshot pushed by the Rust poller. main.ts
// picks the Tauri API, or a browser stand-in, and calls `start`.
import {
COLUMNS, RECENT, WINDOWS, MANUAL, windowById, value, orderedEntries, nextSort, dropIndex,
freshness, ago, totals, urgentAcross,
} from './model.ts';
import type { Column, Meta, RepoEntry, Snapshot, Sort } from './model.ts';
/** What the UI needs from the host: Tauri commands and events, window chrome. */
export interface Api {
invoke<T = unknown>(cmd: string, args?: Record<string, unknown>): Promise<T>;
listen<T>(event: string, fn: (payload: T) => void): void;
startResize(): void;
}
type View = 'repos' | 'urgent';
interface State {
snap: Snapshot | null;
meta: Meta;
view: View;
sort: Sort;
order: string[];
window: string;
urgentRepo: string;
criticalOnly: boolean;
}
const $ = <T extends HTMLElement = HTMLElement>(id: string): T => {
const n = document.getElementById(id);
if (!n) throw new Error(`#${id} is missing from index.html`);
return n as T;
};
function load<T>(k: string, d: T): T {
try { return (JSON.parse(localStorage.getItem(k) ?? 'null') as T | null) ?? d; } catch { return d; }
}
function save(k: string, v: unknown): void {
try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* private mode */ }
}
// ───────────────────────────── formatting ─────────────────────────────
type Child = Node | string | null | undefined | false;
type Props = Record<string, unknown>;
function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Props = {}, ...kids: (Child | Child[])[]): HTMLElementTagNameMap[K] {
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 = String(v);
else if (k === 'dataset') Object.assign(n.dataset, v);
else if (k.startsWith('on')) n.addEventListener(k.slice(2), v as EventListener);
else n.setAttribute(k, String(v));
}
for (const kid of kids.flat()) if (kid !== null && kid !== undefined && kid !== false) n.append(kid);
return n;
}
const localTime = (iso: string | null | undefined): string =>
(iso ? new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : '');
const clock = (t: number): string => new Date(t).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const num = (n: number | string | null | undefined): string => Number(n ?? 0).toLocaleString('en-US');
const gh = (repo: string, path = ''): string => `https://github.com/${repo}${path}`;
const shortName = (repo: string): string => repo.split('/').pop() ?? repo;
const classes = (...cs: (string | false | null | undefined)[]): string => cs.filter(Boolean).join(' ');
const DAY = 86400e3;
/** Starts the UI in the current document. Call once; returns a function that stops its clock. */
export function start(api: Api): () => void {
const state: State = {
snap: null,
meta: {},
view: (new URLSearchParams(location.search).get('view') ?? load<string>('view', 'repos')) === 'urgent' ? 'urgent' : 'repos',
sort: load<Sort>('sort', { key: 'urgency', dir: 'desc' }),
order: load<string[]>('order', []),
window: windowById(load('window', '')).id,
urgentRepo: '',
criticalOnly: load('criticalOnly', false),
};
function openUrl(url: string, ev?: Event): void {
ev?.stopPropagation();
api.invoke('open_url', { url }).catch((e: unknown) => console.warn(e));
}
const link = (text: string, url: string, cls = ''): HTMLElement =>
el('span', { class: `link ${cls}`, onclick: (e: Event) => openUrl(url, e) }, text);
/** The repo's app icon; repos without one (libraries) get a lettered tile, unfetched ones a blank. */
function appIcon(entry: Pick<RepoEntry, 'repo'> & Partial<RepoEntry>): HTMLElement {
if (entry.icon) return el('img', { class: 'app-icon', src: entry.icon, alt: '' });
if (!entry.stats) return el('span', { class: 'app-icon pending', 'aria-hidden': 'true' });
return el('span', { class: 'app-icon none', 'aria-hidden': 'true' }, shortName(entry.repo).charAt(0).toUpperCase());
}
/** Cell content for a column. */
function cell(entry: RepoEntry, col: Column): Node {
const v = value(entry, col.key, state.window);
const s = entry.stats;
if (col.key === 'name') {
const tip = entry.error
? `Last attempt failed: ${entry.error}`
: entry.inaccessible ? 'Not accessible (private, or not found)'
: entry.fetched_at ? `Fetched ${localTime(entry.fetched_at)}` : 'Not fetched yet';
return el('span', { class: 'repo', title: tip }, el('span', { class: 'dot' }), appIcon(entry), link(String(v), gh(entry.repo)));
}
if (!s) return entry.inaccessible ? document.createTextNode('') : el('span', { class: 'muted' }, '—');
switch (col.key) {
case 'urgency': {
const a = Math.min(1, Number(v) / 350) * 0.45;
return el('span', { class: 'urgency', style: `background: rgb(var(--hot-rgb) / ${a.toFixed(2)})` }, String(Math.round(Number(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 'recent_commits': case 'recent_prs': case 'recent_merged': case 'recent_issues': case 'recent_people':
if (v === null) {
const why = state.window === 'release' && !s.latest_build ? 'No release yet' : 'Not fetched yet for this window';
return el('span', { class: 'muted', title: why }, '—');
}
return el('span', {
class: v ? 'active' : 'zero',
title: state.window === 'release' ? `Since ${s.latest_build}, published ${localTime(s.latest_build_at)} (${ago(s.latest_build_at)} ago)` : null,
}, num(v));
case 'commits_since_build': {
const lag = s.since_build;
if (!lag) return el('span', { class: 'muted' }, '—');
const short = (sha: string | null) => sha?.slice(0, 7);
const tip = {
exact: `${num(v)} commits on main since ${s.latest_build} (${short(lag.from_sha)})`,
branched: `${s.latest_build}’s commit isn’t on main: ~${num(v)} commits on main since the two split`,
by_date: `${s.latest_build}’s commit shares no history with main: ~${num(v)} commits on main are newer than it`
+ (lag.from_sha ? `, counted from ${short(lag.from_sha)}` : ''),
}[lag.basis];
const from = lag.basis === 'by_date' ? lag.from_sha : s.latest_build;
const text = `${lag.basis === 'exact' ? '' : '~'}${num(v)}`;
return el('span', { title: tip }, from ? link(text, gh(entry.repo, `/compare/${encodeURIComponent(from)}...HEAD`), v ? '' : 'zero') : text);
}
case 'latest_build':
if (!v) return el('span', { class: 'muted' }, '—');
return el('span', {}, link(String(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(String(v)) > 14 * DAY;
return el('span', { title: localTime(String(v)), class: old ? 'old' : '' }, ago(String(v)));
}
return document.createTextNode(num(v));
}
// ───────────────────────────── rendering ─────────────────────────────
/** The group row, built once: rebuilding it would destroy the window
* dropdown, closing it mid-choice whenever a poll or the clock re-renders. */
let groupRow: HTMLTableRowElement | null = null;
function renderHead(): void {
if (!groupRow) {
const row = el('tr', { class: 'groups' });
let prev: string | null = null;
for (const c of COLUMNS) {
if (c.group === prev) { (row.lastChild as HTMLTableCellElement).colSpan += 1; continue; }
prev = c.group;
row.append(el('th', { class: c.group ? 'group-start' : '' }, c.group === RECENT ? windowPicker() : c.group));
}
$('thead').replaceChildren(row);
groupRow = row;
}
const picker = groupRow.querySelector<HTMLSelectElement>('.window-picker')!;
if (picker.value !== state.window) picker.value = state.window;
const cols = el('tr', { class: 'cols' });
COLUMNS.forEach((c, i) => {
const sorted = state.sort.key === c.key;
cols.append(el('th', {
title: c.title?.replace('{span}', windowById(state.window).span) ?? `Sort by ${c.label}`,
class: classes(sorted && 'sorted', sorted && state.sort.dir === 'asc' && 'asc', groupStart(i)),
onclick: () => {
state.sort = nextSort(state.sort, c.key);
save('sort', state.sort);
render();
},
}, c.label));
});
groupRow.nextSibling?.remove();
$('thead').append(cols);
}
/** The dropdown that heads the recent-activity columns. */
function windowPicker(): HTMLSelectElement {
return el('select', {
class: 'window-picker',
'aria-label': 'Time window for recent activity',
title: 'Time window for the activity columns',
onchange: (e: Event) => { state.window = (e.target as HTMLSelectElement).value; save('window', state.window); render(); },
}, WINDOWS.map((w) => el('option', { value: w.id }, w.label)));
}
function groupStart(i: number): string {
return i > 0 && COLUMNS[i - 1]!.group !== COLUMNS[i]!.group ? 'group-start' : '';
}
function renderTable(snap: Snapshot): void {
renderHead();
const rows = orderedEntries(snap.repos, state.sort, state.order, state.window);
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: classes(stale && 'stale', e.error && 'error'),
dataset: { repo: e.repo },
title: 'Click for this repo’s most urgent issues · drag to reorder',
onpointerdown: startRowDrag,
onclick: () => {
if (justDragged) return;
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, state.window);
$('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] ?? 0)) : ''))));
}
// ───────────────────────────── row dragging ─────────────────────────────
// Pointer events rather than HTML5 drag and drop, which the webview's own
// file-drop handling can swallow. A press turns into a drag once it moves a
// few pixels; until then it is an ordinary click. Dropping saves the order on
// screen and switches the table to it; clicking a column sorts again.
let drag: { row: HTMLTableRowElement; startY: number; moved: boolean } | null = null;
let justDragged = false;
function startRowDrag(e: PointerEvent): void {
if (e.button !== 0 || (e.target as Element).closest('.link')) return;
drag = { row: e.currentTarget as HTMLTableRowElement, startY: e.clientY, moved: false };
window.addEventListener('pointermove', moveRowDrag);
window.addEventListener('pointerup', endRowDrag);
window.addEventListener('pointercancel', cancelRowDrag);
}
function moveRowDrag(e: PointerEvent): void {
if (!drag) return;
if (!drag.moved) {
if (Math.abs(e.clientY - drag.startY) < 5) return;
drag.moved = true;
drag.row.classList.add('dragging');
document.body.classList.add('reordering');
window.getSelection()?.removeAllRanges();
}
const main = $('repos-view');
const box = main.getBoundingClientRect();
if (e.clientY < box.top + 50) main.scrollTop -= 12;
else if (e.clientY > box.bottom - 40) main.scrollTop += 12;
const tbody = $('tbody');
const others = [...tbody.children].filter((r) => r !== drag!.row);
const mids = others.map((r) => { const b = r.getBoundingClientRect(); return b.top + b.height / 2; });
const next = others[dropIndex(mids, e.clientY)] ?? null;
if (drag.row.nextSibling !== next) tbody.insertBefore(drag.row, next);
}
/** Ends a press; true if it had become a drag. */
function finishRowDrag(): boolean {
window.removeEventListener('pointermove', moveRowDrag);
window.removeEventListener('pointerup', endRowDrag);
window.removeEventListener('pointercancel', cancelRowDrag);
document.body.classList.remove('reordering');
const moved = drag?.moved ?? false;
drag = null;
return moved;
}
function endRowDrag(): void {
if (!finishRowDrag()) return;
// The click that follows this pointerup must not open the urgent view.
justDragged = true;
setTimeout(() => { justDragged = false; });
state.order = [...$('tbody').children].map((r) => (r as HTMLElement).dataset.repo ?? '');
state.sort = { key: MANUAL };
save('order', state.order);
save('sort', state.sort);
render();
}
function cancelRowDrag(): void {
if (finishRowDrag()) render();
}
function renderUrgent(snap: Snapshot): void {
const sel = $<HTMLSelectElement>('urgent-repo');
sel.replaceChildren(el('option', { value: '' }, 'All repositories'),
...snap.repos.map((r) => el('option', { value: r.repo }, shortName(r.repo))));
sel.value = state.urgentRepo;
$<HTMLInputElement>('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)})` }, String(u.score)),
el('span', { class: 'u-repo' }, appIcon(snap.repos.find((r) => r.repo === u.repo) ?? { repo: u.repo }), shortName(u.repo)),
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(snap: Snapshot): void {
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: string;
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) => `${shortName(r.repo)} (${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(): void {
// Rebuilding the table mid-drag would drop the row out of the user's hand;
// the drop renders anyway.
const snap = state.snap;
if (!snap || drag?.moved) return;
renderStatus(snap);
document.querySelectorAll<HTMLElement>('.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(snap); else renderUrgent(snap);
}
function setView(v: View): void {
state.view = v;
save('view', v);
render();
}
function applyMeta(m: Meta | null): void {
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<HTMLElement>('.view').forEach((b) => b.addEventListener('click', () => {
const view = b.dataset.view === 'urgent' ? 'urgent' : 'repos';
if (view === 'urgent' && state.view !== 'urgent') state.urgentRepo = '';
setView(view);
}));
$('urgent-repo').addEventListener('change', (e) => { state.urgentRepo = (e.target as HTMLSelectElement).value; render(); });
$('critical-only').addEventListener('change', (e) => {
state.criticalOnly = (e.target as HTMLInputElement).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<Snapshot>('craft:snapshot', (s) => { state.snap = s; render(); });
api.listen<Meta>('craft:meta', applyMeta);
// Relative times and staleness change with the clock alone.
const clockTimer = setInterval(render, 15_000);
(async () => {
try {
applyMeta(await api.invoke<Meta>('get_meta'));
state.snap = await api.invoke<Snapshot>('get_snapshot');
render();
} catch (e) {
console.error(e);
} finally {
// Not requestAnimationFrame: WebKit never fires it while the window is hidden.
api.invoke('window_ready');
}
})();
return () => clearInterval(clockTimer);
}
+27
View File
@@ -0,0 +1,27 @@
// Entry point. Inside Tauri the UI talks to the Rust side; in a plain browser
// it loads ./dev-snapshot.json, written by `make snapshot`, so the UI can be
// iterated on without the app.
import { start } from './app.ts';
import type { Api } from './app.ts';
const TAURI = window.__TAURI__;
const api: Api = TAURI
? {
invoke: (cmd, args) => TAURI.core.invoke(cmd, args),
listen: (ev, fn) => { void TAURI.event.listen(ev, (e) => fn(e.payload as never)); },
startResize: () => { void TAURI.window.getCurrentWindow().startResizeDragging('SouthEast'); },
}
: {
async invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
if (cmd === 'get_snapshot') return (await fetch('dev-snapshot.json')).json() as Promise<T>;
if (cmd === 'get_meta') return { opacity: 0.96, always_on_top: false, shortcut: 'CmdOrCtrl+Shift+G' } as T;
if (cmd === 'open_url') window.open(String(args?.url), '_blank');
return null as T;
},
listen() {},
startResize() {},
};
start(api);
+191
View File
@@ -0,0 +1,191 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
sortEntries, manualOrder, orderedEntries, nextSort, dropIndex, MANUAL, versionKey, freshness, isStale, ago, compact,
totals, urgentAcross, defaultDir, value, windowById, WINDOWS, COLUMNS, COLUMN,
} from './model.ts';
import type { Activity, RepoEntry, RepoStats, UrgentIssue } from './model.ts';
const entry = (repo: string, stats: Partial<RepoStats> | null, fetched_at: string | null = '2026-10-08T12:00:00Z'): RepoEntry =>
({ repo: `o/${repo}`, stats: stats as RepoStats | null, fetched_at });
const issue = (number: number, score: number, critical = false, created_at: string | null = null): UrgentIssue => ({
number, score, critical, created_at, title: `#${number}`, url: '', updated_at: null, comments: 0, reactions: 0, labels: [], reasons: [],
});
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: RepoEntry[]): string => 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('sorting by an unknown column falls back to urgency', () => {
assert.equal(names(sortEntries(rows, 'bogus', 'desc')), 'adbc');
});
test('sorting leaves its input alone', () => {
const before = names(rows);
sortEntries(rows, 'name', 'asc');
manualOrder(rows, ['o/d']);
assert.equal(names(rows), before);
});
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('V2'), [2, 0, 0, 0, 0]);
assert.deepEqual(versionKey('1.2.3-beta'), [1, 2, 3, -1, 0]);
assert.deepEqual(versionKey('nightly'), [-1]);
});
test('commits since the build read through to since_build', () => {
const lagged = [
entry('a', { since_build: { commits: 3, basis: 'exact', from_sha: 'x' } }),
entry('b', { since_build: { commits: 40, basis: 'branched', from_sha: null } }),
entry('c', { since_build: null }),
entry('d', null, null),
];
assert.equal(names(sortEntries(lagged, 'commits_since_build', 'desc')), 'bacd');
assert.equal(value(lagged[2]!, 'commits_since_build'), null);
assert.equal(totals(lagged).commits_since_build, 43);
});
test('dates and names', () => {
assert.equal(names(sortEntries(rows, 'last_commit_at', 'desc')), 'abcd');
assert.equal(names(sortEntries(rows, 'name', 'asc')), 'abcd');
assert.equal(names(sortEntries(rows, 'name', 'desc')), 'dcba');
assert.equal(defaultDir('name'), 'asc');
assert.equal(defaultDir('downloads_total'), 'desc');
assert.equal(defaultDir('oldest_open_pr_at'), 'asc');
});
test('names compare numerically and ignore case', () => {
const e = [entry('app10', {}), entry('App2', {}), entry('app1', {})];
assert.deepEqual(sortEntries(e, 'name', 'asc').map((x) => x.repo), ['o/app1', 'o/App2', 'o/app10']);
});
test('value is null for missing stats and fields, and for non-scalar fields', () => {
assert.equal(value(entry('x', null), 'urgency'), null);
assert.equal(value(entry('x', {}), 'urgency'), null);
assert.equal(value(entry('x', { urgent: [] }), 'urgent'), null);
assert.equal(value(entry('x', { urgency: 0 }), 'urgency'), 0);
assert.equal(value({ repo: 'solo', stats: null, fetched_at: null }, 'name'), 'solo');
});
test('manual order follows the saved list, new repos last in config order', () => {
assert.equal(names(manualOrder(rows, ['o/d', 'o/b'])), 'dbac');
assert.equal(names(manualOrder(rows, ['o/gone', 'o/c'])), 'cbad');
assert.equal(names(manualOrder(rows, [])), 'bacd');
});
test('the table order is manual or by column', () => {
assert.equal(names(orderedEntries(rows, { key: MANUAL }, ['o/c', 'o/a'])), 'cabd');
assert.equal(names(orderedEntries(rows, { key: 'urgency', dir: 'desc' }, ['o/c', 'o/a'])), 'adbc');
});
test('clicking a header flips its direction, or starts a new column at its default', () => {
assert.deepEqual(nextSort({ key: 'urgency', dir: 'desc' }, 'urgency'), { key: 'urgency', dir: 'asc' });
assert.deepEqual(nextSort({ key: 'urgency', dir: 'asc' }, 'urgency'), { key: 'urgency', dir: 'desc' });
assert.deepEqual(nextSort({ key: 'urgency', dir: 'desc' }, 'name'), { key: 'name', dir: 'asc' });
assert.deepEqual(nextSort({ key: MANUAL }, 'urgency'), { key: 'urgency', dir: 'desc' });
assert.deepEqual(nextSort({ key: MANUAL }, 'oldest_open_pr_at'), { key: 'oldest_open_pr_at', dir: 'asc' });
});
test('a dragged row drops before the first row whose middle is below the pointer', () => {
const mids = [10, 30, 50];
assert.equal(dropIndex(mids, 0), 0);
assert.equal(dropIndex(mids, 10), 1, 'exactly on a midpoint goes after it');
assert.equal(dropIndex(mids, 29), 1);
assert.equal(dropIndex(mids, 49), 2);
assert.equal(dropIndex(mids, 99), 3, 'past the last row');
assert.equal(dropIndex([], 5), 0);
});
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('a repo is stale once its data is older than the limit, or never fetched', () => {
const now = Date.parse('2026-10-08T12:10:00Z');
assert.equal(isStale(entry('a', {}), 600, now), false, 'exactly at the limit');
assert.equal(isStale(entry('a', {}), 599, now), true);
assert.equal(isStale(entry('a', null, null), 1e9, now), true);
});
test('formatting', () => {
const now = Date.parse('2026-10-08T12:00:00Z');
assert.equal(ago('2026-10-08T11:59:55Z', now), 'now');
assert.equal(ago('2026-10-08T11:59:15Z', now), '45s');
assert.equal(ago('2026-10-08T11:58:00Z', now), '2m');
assert.equal(ago('2026-10-08T09:00:00Z', now), '3h');
assert.equal(ago('2026-10-06T12:00:00Z', now), '2d');
assert.equal(ago('2024-10-01T12:00:00Z', now), '2y');
assert.equal(ago('2026-10-08T12:05:00Z', now), 'now', 'clock skew never goes negative');
assert.equal(ago(null, now), '');
assert.equal(compact(279524), '280k');
assert.equal(compact(57247), '57.2k');
assert.equal(compact(950), '950');
assert.equal(compact(2_340_000), '2.3M');
assert.equal(compact(null), '');
});
test('totals and urgent merge', () => {
assert.equal(totals(rows).urgency, 110);
assert.equal(totals(rows).latest_build, undefined, 'versions have no total');
const e = [
entry('a', { urgent: [issue(1, 30), issue(2, 80, true)] }),
entry('b', { urgent: [issue(3, 50, 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]);
assert.deepEqual(urgentAcross(e, { limit: 2 }).map((u) => u.number), [2, 3]);
});
test('urgent ties go to the newer issue', () => {
const e = [entry('a', { urgent: [issue(1, 40, false, '2026-10-01T00:00:00Z'), issue(2, 40, false, '2026-10-05T00:00:00Z')] })];
assert.deepEqual(urgentAcross(e).map((u) => u.number), [2, 1]);
});
test('recent-activity columns follow the selected window', () => {
const act = (commits: number, prs_merged: number, people = 0): Activity => ({ commits, prs_opened: 0, prs_merged, issues_opened: 0, people });
const e = [
entry('a', { recent: { '4h': act(5, 1, 2), '7d': act(9, 40), all: act(1234, 300, 12) } }),
entry('b', { recent: { '4h': act(8, 2), '7d': act(8, 10) } }),
entry('old', { commits_4h: 99 } as Partial<RepoStats>),
];
const [a, b, old] = e as [RepoEntry, RepoEntry, RepoEntry];
assert.equal(value(a, 'recent_commits'), 5, 'defaults to 4 h');
assert.equal(value(a, 'recent_merged', '7d'), 40);
assert.equal(value(a, 'recent_commits', '10m'), null, 'window not fetched yet');
assert.equal(value(old, 'recent_commits'), null, 'cache from before windows');
assert.equal(names(sortEntries(e, 'recent_commits', 'desc', '4h')), 'baold');
assert.equal(names(sortEntries(e, 'recent_commits', 'desc', '7d')), 'abold');
assert.equal(totals(e, '7d').recent_merged, 50);
assert.equal(windowById('bogus').id, '4h');
assert.equal(value(a, 'recent_people'), 2);
assert.equal(value(a, 'recent_people', 'all'), 12, 'all time is just another window');
assert.equal(value(b, 'recent_commits', 'all'), null, 'cache from before all time');
assert.equal(names(sortEntries(e, 'recent_merged', 'desc', 'all')), 'abold');
assert.equal(value(a, 'recent_commits', 'release'), null, 'no release');
assert.deepEqual(WINDOWS.map((w) => w.id), ['10m', '30m', '1h', '4h', '12h', '1d', '7d', 'release', 'all']);
});
test('columns: unique keys, every recent column titled with its span', () => {
assert.equal(Object.keys(COLUMN).length, COLUMNS.length);
for (const c of COLUMNS.filter((c) => c.recent)) assert.match(c.title ?? '', /\{span\}/, c.key);
assert.equal(COLUMNS[0]!.key, 'name', 'the sticky first column is the repo');
});
+331
View File
@@ -0,0 +1,331 @@
// Pure helpers: columns, sorting, formatting, staleness. No DOM, so
// `node --test` can exercise them (see model.test.ts).
// ───────────────────────────── data ─────────────────────────────
// What the Rust poller sends: mirrors crates/craft-core/src/model.rs.
// Timestamps are RFC 3339 strings.
export type Time = string;
export interface Activity {
commits: number;
prs_opened: number;
prs_merged: number;
issues_opened: number;
people: number;
}
export interface BuildLag {
commits: number;
basis: 'exact' | 'branched' | 'by_date';
from_sha: string | null;
}
export interface UrgentIssue {
number: number;
title: string;
url: string;
created_at: Time | null;
updated_at: Time | null;
comments: number;
reactions: number;
labels: string[];
score: number;
critical: boolean;
reasons: string[];
}
export interface RepoStats {
open_prs: number;
open_issues: number;
last_commit_at: Time | null;
last_issue_at: Time | null;
oldest_open_pr_at: Time | null;
newest_pr_at: Time | null;
recent: Record<string, Activity>;
latest_build: string | null;
latest_build_at: Time | null;
latest_build_prerelease: boolean;
latest_build_sha: string | null;
since_build: BuildLag | null;
latest_build_downloads: number;
downloads_total: number;
urgency: number;
critical_issues: number;
urgent: UrgentIssue[];
icon_oid: string | null;
}
export interface RepoEntry {
repo: string;
stats: RepoStats | null;
fetched_at: Time | null;
last_attempt_at?: Time | null;
error?: string | null;
inaccessible?: boolean;
icon?: string | null;
icon_oid?: string | null;
}
export interface Snapshot {
repos: RepoEntry[];
last_full_refresh_at: Time | null;
refreshing: boolean;
next_refresh_at: Time | null;
poll_secs: number;
stale_after_secs: number;
rate_limit_remaining: number | null;
error: string | null;
}
/** Window settings and facts from `get_meta` in crates/craft-status-app/src/lib.rs. */
export interface Meta {
config_path?: string;
cache_path?: string;
opacity?: number;
always_on_top?: boolean;
platform?: string;
version?: string;
shortcut?: string;
}
// ───────────────────────────── windows ─────────────────────────────
export interface ActivityWindow {
id: string;
label: string;
span: string;
}
/**
* The activity windows the dropdown offers, shortest first, then since each
* repo's latest release (so a different span per repo; repos without a release
* have no data), then all time. The ids match `WINDOWS`, `SINCE_RELEASE` and
* `ALL_TIME` in crates/craft-core/src/model.rs; every poll fetches all of them.
*/
export const WINDOWS: readonly ActivityWindow[] = [
{ id: '10m', label: 'Last 10 min', span: 'in the last 10 minutes' },
{ id: '30m', label: 'Last 30 min', span: 'in the last 30 minutes' },
{ id: '1h', label: 'Last hour', span: 'in the last hour' },
{ id: '4h', label: 'Last 4 h', span: 'in the last 4 hours' },
{ id: '12h', label: 'Last 12 h', span: 'in the last 12 hours' },
{ id: '1d', label: 'Last day', span: 'in the last day' },
{ id: '7d', label: 'Last week', span: 'in the last week' },
{ id: 'release', label: 'Since last release', span: 'since each repo’s latest release' },
{ id: 'all', label: 'All time', span: 'over all time' },
];
export const DEFAULT_WINDOW = '4h';
export const windowById = (id: string): ActivityWindow =>
WINDOWS.find((w) => w.id === id) ?? windowById(DEFAULT_WINDOW);
// ───────────────────────────── columns ─────────────────────────────
export type Kind = 'text' | 'num' | 'date' | 'version';
export type Value = string | number | null;
export interface Column {
key: string;
label: string;
kind: Kind;
group: string;
title?: string;
/** Sorts ascending when first clicked, though not text. */
asc?: boolean;
/** The field of the selected window's `Activity` this column shows. */
recent?: keyof Activity;
get?: (e: RepoEntry) => Value;
}
/**
* Table columns, in display order. `get` reads from a RepoEntry; numbers and
* dates sort descending first (biggest / newest on top), text ascending.
* Columns with `recent` read that field of the selected window's activity;
* their group is `RECENT`, which the table draws as the window dropdown, and
* `{span}` in their titles names the window.
*/
export const RECENT = 'recent';
export const COLUMNS: readonly Column[] = [
{ key: 'name', label: 'Repo', kind: 'text', group: '', get: (e) => e.repo.split('/').pop() ?? e.repo },
{ 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: 'recent_commits', recent: 'commits', label: 'Commits', kind: 'num', group: RECENT, title: 'Commits to main {span}' },
{ key: 'recent_prs', recent: 'prs_opened', label: 'PRs', kind: 'num', group: RECENT, title: 'Pull requests opened {span}' },
{ key: 'recent_merged', recent: 'prs_merged', label: 'Merged', kind: 'num', group: RECENT, title: 'Pull requests merged {span}' },
{ key: 'recent_issues', recent: 'issues_opened', label: 'Issues', kind: 'num', group: RECENT, title: 'Issues opened {span}' },
{ key: 'recent_people', recent: 'people', label: 'People', kind: 'num', group: RECENT, title: 'People who committed to main {span} (all time: GitHub’s contributor count)' },
{ 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: 'commits_since_build', label: 'Unreleased', kind: 'num', group: 'Builds', title: 'Commits on main since the most recent release (~ means estimated: the release’s commit isn’t on main)', get: (e) => e.stats?.since_build?.commits ?? null },
{ 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: Readonly<Record<string, Column>> = Object.fromEntries(COLUMNS.map((c) => [c.key, c]));
/**
* The value a column shows for an entry; `null` when there is no data
* (including a cache written before that window existed).
*/
export function value(entry: RepoEntry, key: string, window: string = DEFAULT_WINDOW): Value {
const col = COLUMN[key];
if (col?.get) return col.get(entry);
if (col?.recent) return entry.stats?.recent?.[window]?.[col.recent] ?? null;
const v = entry.stats ? (entry.stats as unknown as Record<string, unknown>)[key] : null;
return typeof v === 'string' || typeof v === 'number' ? v : null;
}
// ───────────────────────────── sorting ─────────────────────────────
export type Dir = 'asc' | 'desc';
/** The table's order: by a column, or `MANUAL` (the rows as last dragged). */
export type Sort = { key: string; dir?: Dir };
/** `v0.10.2-rc.1` → [0, 10, 2, -1, 1] so pre-releases sort below the release. */
export function versionKey(tag: string): number[] {
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: number[], b: number[]): number {
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: Kind, a: string | number, b: string | number): number {
switch (kind) {
case 'text': return String(a).localeCompare(String(b), undefined, { sensitivity: 'base', numeric: true });
case 'date': return Date.parse(String(a)) - Date.parse(String(b));
case 'version': return cmpArrays(versionKey(String(a)), versionKey(String(b)));
default: return Number(a) - Number(b);
}
}
const byName = (a: RepoEntry, b: RepoEntry): number => cmpValues('text', value(a, 'name') ?? '', value(b, 'name') ?? '');
/** The direction a column sorts in when first clicked. */
export function defaultDir(key: string): Dir {
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: readonly RepoEntry[], key: string, dir: Dir | undefined, window: string = DEFAULT_WINDOW): RepoEntry[] {
const col = COLUMN[key] ?? COLUMN.urgency!;
const sign = dir === 'asc' ? 1 : -1;
return [...entries].sort((a, b) => {
const va = value(a, col.key, window);
const vb = value(b, col.key, window);
if (va === null && vb === null) return byName(a, b);
if (va === null) return 1;
if (vb === null) return -1;
return sign * cmpValues(col.kind, va, vb) || byName(a, b);
});
}
/** The `sort.key` of a hand-arranged table: rows follow a saved list of repos. */
export const MANUAL = 'manual';
/** Entries in the order of `order` (repo names); repos missing from it keep config order, after the rest. */
export function manualOrder(entries: readonly RepoEntry[], order: readonly string[]): RepoEntry[] {
const rank = new Map(order.map((repo, i) => [repo, i]));
const key = (e: RepoEntry, i: number) => rank.get(e.repo) ?? order.length + i;
return entries.map((e, i) => [key(e, i), e] as const).sort((a, b) => a[0] - b[0]).map(([, e]) => e);
}
/** The table's rows for a sort, manual or by column. */
export function orderedEntries(entries: readonly RepoEntry[], sort: Sort, order: readonly string[], window: string = DEFAULT_WINDOW): RepoEntry[] {
return sort.key === MANUAL ? manualOrder(entries, order) : sortEntries(entries, sort.key, sort.dir, window);
}
/** The sort after clicking a column header: flip it if already sorted by it, else its default direction. */
export function nextSort(sort: Sort, key: string): Sort {
return sort.key === key
? { key, dir: sort.dir === 'asc' ? 'desc' : 'asc' }
: { key, dir: defaultDir(key) };
}
/**
* Where a dragged row goes: the index, among the other rows, of the first row
* whose vertical midpoint is below the pointer (`mids.length` for the end).
*/
export function dropIndex(mids: readonly number[], y: number): number {
const i = mids.findIndex((m) => y < m);
return i === -1 ? mids.length : i;
}
// ───────────────────────────── freshness ─────────────────────────────
export function isStale(entry: RepoEntry, staleAfterSecs: number, now: number = Date.now()): boolean {
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: Pick<Snapshot, 'repos' | 'stale_after_secs'>, now: number = Date.now()): { oldest: number | null; stale: boolean } {
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 as number[])) : null;
const stale = !snap.repos.length || snap.repos.some((r) => isStale(r, snap.stale_after_secs, now));
return { oldest, stale };
}
// ───────────────────────────── formatting ─────────────────────────────
/** "now", "45s", "12m", "3h", "5d", "2y" — compact for table cells. */
export function ago(iso: Time | null | undefined, now: number = Date.now()): string {
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: number | null | undefined): string {
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: readonly RepoEntry[], window: string = DEFAULT_WINDOW): Record<string, number> {
const out: Record<string, number> = {};
for (const c of COLUMNS) {
if (c.kind !== 'num') continue;
out[c.key] = entries.reduce((sum, e) => sum + Number(value(e, c.key, window) ?? 0), 0);
}
return out;
}
export type RepoUrgentIssue = UrgentIssue & { repo: string };
/** Every repo's urgent issues merged, best first. */
export function urgentAcross(
entries: readonly RepoEntry[],
{ repo = '', criticalOnly = false, limit = 60 }: { repo?: string; criticalOnly?: boolean; limit?: number } = {},
): RepoUrgentIssue[] {
const all: RepoUrgentIssue[] = [];
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);
}
+26
View File
@@ -0,0 +1,26 @@
// Repo rules that a reviewer could miss. See CLAUDE.md.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
const root = new URL('../../', import.meta.url);
test('no JavaScript is committed: the UI is TypeScript', () => {
const js = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '*.js', '*.mjs', '*.cjs', '*.jsx'], { cwd: root, encoding: 'utf8' })
.split('\n').filter(Boolean);
assert.deepEqual(js, [], 'write these in TypeScript under ui/src/ instead');
});
test('index.html runs no inline script, only the compiled TypeScript', () => {
const html = readFileSync(new URL('ui/index.html', root), 'utf8');
const scripts = [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/g)];
assert.ok(scripts.length > 0);
for (const [tag, attrs, body] of scripts) {
assert.match(attrs!, /\bsrc="build\/[\w-]+\.js"/, `${tag} must load a file built from ui/src/`);
assert.equal(body!.trim(), '', `${tag} must not have inline code`);
}
assert.doesNotMatch(html, /\son[a-z]+=/i, 'no inline event handlers');
assert.doesNotMatch(html, /javascript:/i, 'no javascript: URLs');
});
+7
View File
@@ -0,0 +1,7 @@
// Surfaces load and runtime errors in the app's stderr. Loaded before main.ts
// so it also catches errors from loading the other modules, which never reach
// their own code.
const report = (m: unknown): void => { void 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}`));
+11
View File
@@ -0,0 +1,11 @@
// The slice of Tauri's global API (`app.withGlobalTauri`) the UI uses.
interface TauriGlobal {
core: { invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> };
event: { listen<T>(event: string, fn: (e: { payload: T }) => void): Promise<() => void> };
window: { getCurrentWindow(): { startResizeDragging(direction: 'SouthEast'): Promise<void> } };
}
interface Window {
__TAURI__?: TauriGlobal;
}