Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ name: Publish statement gallery to Pages
on:
workflow_dispatch:
push:
branches: ["main", "claude/meta-audit-priorities-MzByf"]
branches: ["main", "claude/meta-audit-priorities-MzByf", "claude/inspiring-fermi-FIdYr"]
paths:
- "docs/statements/**"
- ".github/workflows/pages.yml"
Expand Down
108 changes: 108 additions & 0 deletions docs/statements/APP-ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# From mockup to app — the honest roadmap

> The question was "how do we get this into an app store?" The honest answer is that
> the app store is the *last* step, not the first — and most of the work between here
> and there is backend, not app. This is the real path, in order, with what's true at
> each stage. Nothing fabricated ships as real at any phase.

---

## Where we are

The statements are a **working generator** (`collect → compose → generate`). Three of
the four data sources are real today (Pi-hole, Uptime Kuma, WireGuard); the fourth
(volume by category) needs the nftables layer stood up. The multi-home "Alliance"
portfolio is **sample data** — there is one real home behind this today.

So "an app" has to answer: an app *of what data, for whom*? That defines the phases.

---

## Phase 0 — A phone/laptop demo, today ✅ (done)

The gallery is now an installable **PWA**: `manifest.webmanifest` + `sw.js` + icons,
wired into `index.html`. Published via the existing Pages workflow it:

- opens at a real URL on **any laptop or phone** browser;
- **installs** to the home screen / dock (Add to Home Screen on iOS, Install on
Chrome/Edge desktop + Android) with the A777ance icon, no app store;
- works **offline** once opened (handy for demoing with no signal).

This is not throwaway: the PWA *is* the UI a native wrapper reuses verbatim in Phase 4.

**To publish:** repo **Settings → Pages → Source: "GitHub Actions"** (one-time), then
the `pages.yml` workflow deploys `docs/statements/` to
`https://<owner>.github.io/<repo>/`.

---

## Phase 1 — Make one home's numbers real

Close the one measured-data gap so the demo shows *this* home, not samples.

- Stand up the volume layer: `collect/nftables-accounting.nft` + `collect/populate_sets.py`
(see `collect/README.md`).
- Run `collect_stats.py` → `compose.py` → `generate_client.py` on the box on a schedule.
- **Gate:** only figures that are measured get printed as real. Peer benchmarks stay
labeled illustrative until there is a real cohort dataset.

---

## Phase 2 — The service backend (the real prerequisite for an app)

The QR codes already promise things that **don't exist yet**:

| QR target | Promises | Needs |
| --------- | -------- | ----- |
| `status.a777ance.com/<home>` | live per-home status page | a hosted status view + per-account scoping |
| `my.a777ance.com/<acct>` | password-protected online statement | **auth** + an API serving each account's statement JSON + history |
| `alliance.a777ance.com/connect/<id>` | opt-in member directory | a directory service (only if/when the Alliance is real) |

This is the actual "backend." An app is just a client to it. Minimum viable:

1. A host — the t630 can serve it behind the existing tunnel, or a small VPS.
2. Auth — one login per account; statements scoped to the logged-in home.
3. An API — `GET /statement/<period>` returns the JSON the generator already
produces; `GET /status` returns live Uptime-Kuma-backed health.
4. TLS + the real domains.

No "Claude" piece is required here — it's a standard small web stack. (Claude Haiku
for richer statement prose, `compose_prose()`, stays an optional, default-off add-on.)

---

## Phase 3 — Multi-home / operator reality

The operator portfolio (`generate_operator.py`) becomes a real document only when
there is **more than one real home** under management. Today its 20-home book is
`random.seed(77)` sample data and must stay labeled as such. When real homes exist,
add a roll-up step (per-home JSON → `portfolio.json`); until then, don't manufacture
a fleet.

---

## Phase 4 — Native app + the stores

Only now does "app store" enter. The PWA from Phase 0 wraps directly:

- **Wrapper:** Capacitor (or similar) loads the web UI into iOS/Android shells —
reuses ~100% of what's built. Add native bits only if needed (push, biometrics).
- **Apple App Store:** Apple Developer Program ($99/yr; identity verification can
take a day+), App Store Connect setup, privacy labels, then **App Review** — count
on days and at least one rejection round, not same-day.
- **Google Play:** $25 one-time. New **personal** developer accounts must run a
closed test with ~20 testers for ~14 days before production access (org accounts
differ). So Play isn't same-day for a fresh personal account either.

**Realistic timeline:** ~2–6 weeks from "Phase 2 backend done" to a live listing —
dominated by backend + review, not by the app shell.

---

## The honesty gate (every phase)

The repo's own rule: *"nothing here is aspirational"* (SKILLS.md), *"don't print
invented peer averages on a kept document"* (statements README). A document that
goes out as real — on paper, in an inbox, or in an app — only shows figures we can
stand behind. Samples are fine; samples labeled as real are not. This is also what
keeps a public app-store submission out of "misleading content" rejection territory.
8 changes: 6 additions & 2 deletions docs/statements/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ GitHub shows `.html` as source code, not a rendered page, so there are three way
One-time setup: **Settings → Pages → Source: "GitHub Actions"**. The
[`pages.yml`](../../.github/workflows/pages.yml) workflow then regenerates and publishes
`docs/statements/` on every change; the gallery lands at the site root
(`https://<owner>.github.io/<repo>/`).
(`https://<owner>.github.io/<repo>/`). Published this way the gallery is an **installable
PWA** — *Add to Home Screen* on a phone, *Install* in Chrome/Edge on a laptop; it opens
full-screen and works offline. The path from here to a native app is in
[`APP-ROADMAP.md`](APP-ROADMAP.md).

---

Expand Down Expand Up @@ -119,6 +122,7 @@ Everything renders from JSON — the template never holds data. The flow:
| [`tools/collect/collect_stats.py`](tools/collect/collect_stats.py) | On-box gatherer (Pi-hole / Kuma / wg / nft). `--sample` runs anywhere |
| [`tools/collect/categories.json`](tools/collect/categories.json) | Domain → category map |
| [`tools/collect/nftables-accounting.nft`](tools/collect/nftables-accounting.nft) | The flow-accounting ruleset that yields per-category bytes |
| [`tools/collect/populate_sets.py`](tools/collect/populate_sets.py) | Resolves `categories.json` → IPs and feeds the nft sets — the piece that makes volume real. Runbook in [`collect/README.md`](tools/collect/README.md) |
| [`tools/collect/sample-sidecar.json`](tools/collect/sample-sidecar.json) | Example operator sidecar (narrative + cohort + prior/YTD) |

Regenerate the mockups (only dependency is `segno`):
Expand Down Expand Up @@ -153,7 +157,7 @@ Read before shipping to a paying client. Be honest about which figures are real:
| Uptime, peak latency | Uptime Kuma `/metrics` | **Real** — available today |
| VPN session count | `wg show` | **Real** — available today |
| "Handled For You" log | The operator's own change record (the sidecar) | **Real** — operator-maintained |
| **Traffic volume in GB, by category** | nftables accounting (`nftables-accounting.nft`) | **Buildable** — the ruleset + IP-set populator are scaffolded here; stand it up on the box before these sections are real |
| **Traffic volume in GB, by category** | nftables accounting (`nftables-accounting.nft` + `populate_sets.py`) | **Built — run it on the box.** Ruleset *and* IP-set populator now both ship here; load the ruleset, schedule `populate_sets.py`, and these sections are real (runbook: [`collect/README.md`](tools/collect/README.md)). Until then the committed examples use sample volume. |
| Demographic benchmark averages | the cohort block in the sidecar | **Needs a real cohort dataset**, or it's a placeholder. Don't print invented peer averages on a kept document. |

The committed examples use **sample data** for the volume-based sections. Before these go out for
Expand Down
Binary file added docs/statements/icons/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/statements/icons/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/statements/icons/icon-512-maskable.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/statements/icons/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions docs/statements/icons/icon-maskable.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions docs/statements/icons/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions docs/statements/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>A777ance — Statement Gallery</title>
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#13314f">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="A777ance">
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
<link rel="icon" type="image/svg+xml" href="icons/icon.svg">
<style>
*{box-sizing:border-box;margin:0;padding:0;}
:root{--navy:#13314f;--ink:#1f2733;--bronze:#a9803f;--bronze-soft:#c6a463;--paper:#fbfaf7;--rule:#e6e2d6;--muted:#8a93a0;}
Expand All @@ -19,6 +27,8 @@
.lead h1{font-family:Georgia,serif;font-size:26px;color:var(--navy);font-weight:700;}
.lead p{font-size:14px;color:#3a4553;line-height:1.65;margin-top:10px;}
.lead .note{margin-top:14px;font-size:12px;color:#5a5446;background:#f2efe7;border-left:3px solid var(--bronze);padding:11px 14px;border-radius:0 6px 6px 0;}
.lead .install{margin-top:12px;font-size:12px;color:#3a4553;line-height:1.55;}
.lead .install b{color:var(--navy);}
.sect{margin-top:38px;}
.sect-h{display:flex;align-items:baseline;gap:12px;border-bottom:1px solid var(--rule);padding-bottom:10px;margin-bottom:22px;}
.sect-h h2{font-size:13px;text-transform:uppercase;letter-spacing:0.16em;color:var(--navy);}
Expand Down Expand Up @@ -54,6 +64,9 @@ <h1>Statement Gallery</h1>
figures and the "Handled For You" log are real-data-ready today; the volume-by-category
sections need a flow-accounting layer first. See the
<a href="README.md">README</a> for the honest data-sourcing breakdown.</div>
<p class="install"><b>Tip —</b> install this like an app: on a phone, open this page and choose
<b>Add to Home Screen</b>; on a laptop (Chrome/Edge), click <b>Install</b> in the address bar.
It opens full-screen and works offline — no app store needed.</p>
</div>

<div class="wrap">
Expand Down Expand Up @@ -100,5 +113,12 @@ <h1>Statement Gallery</h1>
single self-contained file. &nbsp;·&nbsp; A777ANCE · hello@a777ance.com
</div>
</div>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('sw.js').catch(function () {});
});
}
</script>
</body>
</html>
16 changes: 16 additions & 0 deletions docs/statements/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "A777ance — Network Activity Statements",
"short_name": "A777ance",
"description": "Monthly residential network activity statements — the gallery of client and operator statements.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#13314f",
"theme_color": "#13314f",
"icons": [
{ "src": "icons/icon.svg", "type": "image/svg+xml", "sizes": "any", "purpose": "any" },
{ "src": "icons/icon-192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
{ "src": "icons/icon-512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" },
{ "src": "icons/icon-512-maskable.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" }
]
}
51 changes: 51 additions & 0 deletions docs/statements/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/* A777ance statement gallery — service worker.
* Makes the gallery installable (PWA) and openable offline on phone and laptop.
* Cache-first for the known static assets; network with an offline fallback for
* everything else. Bump CACHE when the published assets change. */
const CACHE = 'a777ance-statements-v1';

// Precache the shell. Only list files that always exist, so install never fails.
const ASSETS = [
'./',
'./index.html',
'./manifest.webmanifest',
'./client/archetype-prime-time.html',
'./client/archetype-home-office.html',
'./client/archetype-connected-family.html',
'./operator/alliance-member-portfolio.html',
'./previews/archetype-prime-time.png',
'./previews/archetype-home-office.png',
'./previews/archetype-connected-family.png',
'./previews/alliance-member-portfolio.png',
'./icons/icon.svg'
];

self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(ASSETS)).then(() => self.skipWaiting())
);
});

self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});

self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then((hit) => {
if (hit) return hit;
return fetch(event.request)
.then((resp) => {
const copy = resp.clone();
caches.open(CACHE).then((cache) => cache.put(event.request, copy));
return resp;
})
.catch(() => caches.match('./index.html'));
})
);
});
89 changes: 89 additions & 0 deletions docs/statements/tools/collect/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# `collect/` — the on-box data layer

This is where a statement's **measured** figures come from. Everything here runs on
the t630; nothing here writes narrative copy (that's `compose.py` + the operator
sidecar).

```
collect_stats.py ──► stats.json ──► ../compose.py ──► ../data/clients/<home>.json
▲ ▲ ▲ ▲
│ │ │ └─ nftables counters ......... per-category VOLUME (this dir sets up)
│ │ └──── wg show ................... VPN sessions (real today)
│ └─────── Uptime Kuma /metrics ...... uptime, peak latency (real today)
└────────── Pi-hole FTL (sqlite) ...... queries/blocks/devices(real today)
```

Three of the four sources work the moment the box is up. The fourth — **volume by
category**, which feeds the Traffic Allocation donut and the Household Profile
"macros" — needs the flow-accounting layer in this folder stood up first. Until
then those sections render from sample data. Be honest about that with clients.

---

## Files

| File | What it is |
| ---- | ---------- |
| `collect_stats.py` | Gathers one home's measured figures. `--sample` runs anywhere. |
| `categories.json` | Domain → category map. The buckets must match the statement template. |
| `nftables-accounting.nft` | The counting ruleset: one IP-set + one byte-counter per category, hooked on `forward`. |
| `populate_sets.py` | Resolves `categories.json` domains → IPs and feeds the sets. **The piece that makes volume real.** |
| `sample-sidecar.json` | Example operator sidecar (the facts the box can't measure). |

---

## Standing up the volume layer (the only part that needs building)

The counters exist but start at zero because the IP-sets are empty. Two steps fix
that — load the ruleset, then keep the sets fed.

**1. Load the accounting ruleset** (idempotent; re-run anytime):

```bash
sudo nft -f nftables-accounting.nft
sudo nft list table inet a777acct # sets + counters now exist, all zero
```

**2. Populate the sets from `categories.json`.** Dry-run first (resolves real DNS,
touches nothing — works on any machine):

```bash
python3 populate_sets.py | head # prints the `add element …` script
sudo python3 populate_sets.py --apply # on the box: actually programs the sets
```

Elements carry a `timeout` (default 24h), so CDN IPs that rotate away age out on
their own. That means the populator must run **on a schedule**, not once.

**3. Cron** (resolve a few times a day; collect monthly):

```cron
# refresh category IP-sets every 6h (CDN IPs rotate; set elements time out in 24h)
3 */6 * * * /usr/bin/python3 /home/USER/a777ance/collect/populate_sets.py --apply >/dev/null 2>&1

# collect the running month's measured stats nightly
30 0 * * * /usr/bin/python3 /home/USER/a777ance/collect/collect_stats.py \
--out /var/lib/a777ance/$(date +\%Y-\%m).stats.json
```

**4. Read the counters** (what `collect_stats.py` does internally):

```bash
sudo nft -j list counters table inet a777acct
```

---

## Notes & limits

- **IPv4 only.** The sets are `ipv4_addr`. Adding AAAA would need parallel `ip6`
sets + rules — out of scope until there's a reason.
- **Category accuracy is approximate.** A domain can sit behind a shared CDN, so a
byte can land in the "wrong" bucket. This is fine for a *macros* view (balance,
not precision) and is exactly why the statement reports categories, never domains.
- **Privacy holds.** Only per-category byte totals are kept — no per-domain logging,
nothing leaves the box. That's the promise printed on every statement; this layer
must not break it.
- **One home today.** This measures the home the box serves. The multi-home operator
portfolio only becomes real with more than one real home behind it — see
`../../APP-ROADMAP.md`.
Loading
Loading