diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..c4179a4
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,6 @@
+# Keep the repo normalized to LF, but check Windows launchers out with CRLF:
+# cmd.exe can misparse LF-only batch files (labels and parenthesized blocks).
+* text=auto
+*.cmd text eol=crlf
+*.ps1 text eol=crlf
+*.png binary
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..b034b5e
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,32 @@
+# Runs the test suite on Windows (HogWatch uses Windows-only APIs: ICMP via
+# iphlpapi and ETW) for every pull request and every push to main.
+name: tests
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: windows-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+ cache: pip
+
+ - name: Install dependencies
+ run: python -m pip install -r requirements.txt
+
+ - name: Unit and integration tests
+ run: python -m unittest discover -s tests -v
+
+ - name: Dashboard script syntax
+ run: node --check hogwatch/static/app.js
diff --git a/README.md b/README.md
index 658a19c..f8b51dd 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,17 @@
# HogWatch
+[](https://github.com/MSabacreationslab/hogwatch/actions/workflows/tests.yml)
+
Finds out **who** is slowing the home internet to a crawl, **when**, and **what
they're probably doing**, and shows it on a dashboard at .
+
+
+
+
+
+Screenshot uses demo data; device names and rooms are made up.
+
It watches three things at once:
| What | How | Needs |
diff --git a/docs/dashboard-dark.png b/docs/dashboard-dark.png
new file mode 100644
index 0000000..8c739eb
Binary files /dev/null and b/docs/dashboard-dark.png differ
diff --git a/docs/dashboard-light.png b/docs/dashboard-light.png
new file mode 100644
index 0000000..68696ae
Binary files /dev/null and b/docs/dashboard-light.png differ
diff --git a/hogwatch/static/app.js b/hogwatch/static/app.js
index 38be047..79eaf5a 100644
--- a/hogwatch/static/app.js
+++ b/hogwatch/static/app.js
@@ -26,7 +26,7 @@ const s = (tag, attrs, ...kids) => build(document.createElementNS(SVGNS, tag), a
/* ------------------------------------------------------------------ formatting */
const fmtRate = (x) => (x == null ? "–" : x < 0.05 ? "0" : x < 10 ? x.toFixed(1) : Math.round(x).toLocaleString());
-const fmtMs = (x) => (x == null ? "–" : Math.round(x).toLocaleString());
+const fmtMs = (x) => (x == null ? "–" : x < 1 ? "<1" : Math.round(x).toLocaleString());
function fmtVolume(mb) {
if (mb == null) return "–";
if (mb >= 1000) return (mb / 1000).toFixed(mb >= 10000 ? 0 : 1) + " GB";
@@ -56,6 +56,9 @@ const KIND_LABEL = {
const state = { hours: 1, now: null, timeline: null, usage: null, incidents: null, incidentLimit: 15, offline: false };
try { const saved = +localStorage.getItem("hw-hours"); if (saved) state.hours = saved; } catch (e) { /* storage blocked */ }
+// A link like /?hours=24 opens that range directly (handy for bookmarks).
+const linkedHours = +new URLSearchParams(location.search).get("hours");
+if ([1, 6, 24, 168].includes(linkedHours)) state.hours = linkedHours;
async function getJSON(path) {
const r = await fetch(path, { cache: "no-store" });