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
5 changes: 5 additions & 0 deletions .changeset/stats-daily-energy-reports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": patch
---

The project stats page shows energy from FTW Cloud's anonymous daily reports: the newest public day's report count and its solar, grid, battery and vehicle sums, and the public days of the last 30. A day appears once it closes for reports and holds at least five. These are reports, not homes.
11 changes: 11 additions & 0 deletions stats/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ uses the Cloud view in place of the archive and never adds them together. The se
policy below supplies installation, capacity and energy cells. Never merge
these two report types or treat check-ins as people.

Energy comes from FTW Cloud's anonymous daily energy reports, at
`/v1/energy/public`. Each box sends one report per closed UTC day: the date
and six whole-kWh sums. It goes through an independent Oblivious HTTP relay,
so Cloud never learns which box sent it. Cloud lists a day only once no
report may still name it, four days after it ends, and only with at least
five reports. The Worker parses that feed strictly, keeps the latest copy and
shows the newest public day and the sum over the public days of the last 30.
These are reports, not homes; never add them to the hourly usage cells. Once
Core names the relay, the fleet report takes it too, as a separate message at
its own time; `/v1/fleet/public` does not change.

The signed legacy ingest route stays for compatibility. It accepts group
counters only, never routed frames, room handles, box ids, serials, IP
addresses, site names or raw household reports.
Expand Down
6 changes: 6 additions & 0 deletions stats/migrations/0004_cloud_energy.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Latest public daily energy report totals only; never a single report.
CREATE TABLE cloud_energy_view (
id INTEGER PRIMARY KEY CHECK(id=1),
observed_at TEXT NOT NULL,
view_json TEXT NOT NULL
);
43 changes: 40 additions & 3 deletions stats/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,24 @@ export function dashboardDocument(privateView: boolean): DashboardDocument {
<div><span>Vehicle charge / discharge</span><strong id="usage-vehicle-energy">—</strong><small>Recorded hours · kWh</small></div>
</div>
<p id="usage-note" class="panel-copy">The Cloud usage source is not connected yet. Values appear after privacy review, policy activation and updated owner terms.</p>
<p class="footnote">Installation counts do not count people or all FTW boxes. Configured limits do not prove physical control. Energy sums the recorded hours in a 24-hour window; gaps reduce coverage. Withheld cells show no value.</p>
<h3 class="usage-subhead">Daily energy reports</h3>
<div class="mini-grid usage-grid">
<div><span>Reports</span><strong id="energy-reports">—</strong><small id="energy-day">Newest public day</small></div>
<div><span>Solar generation</span><strong id="energy-solar">—</strong><small>Sum of reports · kWh</small></div>
<div><span>Grid import / export</span><strong id="energy-grid">—</strong><small>Sum of reports · kWh</small></div>
<div><span>Battery charge / discharge</span><strong id="energy-battery">—</strong><small>Sum of reports · kWh</small></div>
<div><span>Vehicle charge</span><strong id="energy-vehicle">—</strong><small>Sum of reports · kWh</small></div>
<div><span>Public days</span><strong id="energy-window">—</strong><small id="energy-window-note">Last 30 days</small></div>
</div>
<p id="energy-note" class="panel-copy">Daily energy reports appear once FTW Cloud publishes them.</p>
<p class="footnote">Installation counts do not count people or all FTW boxes. Configured limits do not prove physical control. Hourly energy sums the recorded hours in a 24-hour window; gaps reduce coverage. Withheld cells show no value. A daily report is one box's whole UTC day, sent without any id through an independent relay, so FTW Cloud cannot tell which box sent it; a box with a gap in its day sends none. Reports are not homes.</p>
</section>

<section class="panel" aria-labelledby="coverage-title">
<div class="panel-head"><div><p class="eyebrow">DATA SOURCES</p><h2 id="coverage-title">What this page covers</h2></div></div>
<p id="coverage-repos" class="panel-copy">Six active public repositories. Private repositories and archived experiments are outside these totals.</p>
<p class="panel-copy">The separate relay is retired. Anonymous daily reports now come through FTW Cloud. The updated Cloud terms include statistics for all Cloud users. Local FTW users can choose to contribute without remote access. Hourly collection awaits privacy review. App Store installs are not collected. Native app development appears below.</p>
<p class="footnote">Missing usage values stay unknown. Public energy cells need at least 20 contributing installations. Historical relay reports remain in the archive below.</p>
<p class="footnote">Missing usage values stay unknown. Public hourly energy cells need at least 20 contributing installations; a public day of energy reports needs at least 5 reports. Historical relay reports remain in the archive below.</p>
</section>

<section aria-labelledby="headline-title">
Expand Down Expand Up @@ -211,7 +221,9 @@ export function dashboardDocument(privateView: boolean): DashboardDocument {
}

function renderFreshness(freshness, privateMode) {
const labels = [['github', 'GitHub', 2], ['github_traffic', 'Repo traffic', 30], ['site', 'Web app', 2], ['cloud_fleet', 'Daily reports', 2], ['usage', 'Energy reports', 2]];
// The daily energy feed gains a day once a day, so it is stale after 48
// hours (ENERGY_STALE_HOURS); the hourly feeds after 2.
const labels = [['github', 'GitHub', 2], ['github_traffic', 'Repo traffic', 30], ['site', 'Web app', 2], ['cloud_fleet', 'Daily reports', 2], ['usage', 'Hourly energy', 2], ['cloud_energy', 'Daily energy', 48]];

const host = $('freshness');
host.replaceChildren();
Expand Down Expand Up @@ -287,6 +299,29 @@ export function dashboardDocument(privateView: boolean): DashboardDocument {
if (u.state === 'connected') setText('usage-note', 'Window: ' + u.window_start + ' to ' + u.window_end + '. Each value needs at least 20 contributors; a household above 30% of a sum withholds that cell. ' + (Date.now() - Date.parse(u.observed_at) > 2 * 3600000 ? 'Source is stale; these are the last saved values.' : 'Source: FTW Cloud.'));
}

function renderEnergyReports(energy) {
const e = energy || {};
const kwh = (n) => typeof n === 'number' ? new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(n) : '—';
const d = e.latest;
// Stale numbers stay readable but never pass for current ones.
const stale = e.stale ? ' · stale source' : '';
setText('energy-reports', d ? number(d.reports) : '—');
setText('energy-day', d ? d.date + ' · UTC day' + stale : 'Newest public day');
setText('energy-solar', kwh(d && d.pv_generation_kwh));
setText('energy-grid', kwh(d && d.grid_import_kwh) + ' / ' + kwh(d && d.grid_export_kwh));
setText('energy-battery', kwh(d && d.battery_charge_kwh) + ' / ' + kwh(d && d.battery_discharge_kwh));
setText('energy-vehicle', kwh(d && d.vehicle_charge_kwh));
const w = e.window;
setText('energy-window', w ? number(w.days) : '—');
const solar = w && (w.pv_generation_days === w.days ? kwh(w.pv_generation_kwh) + ' kWh solar' : kwh(w.pv_generation_kwh) + ' kWh solar on ' + number(w.pv_generation_days) + ' of them');
setText('energy-window-note', w ? number(w.reports) + ' reports · ' + solar + stale : 'Last 30 days');
const rule = 'A day appears once it closes for reports, four days after it ends, and needs at least ' + (e.minimum || 5) + ' reports; a sum fewer than ' + (e.minimum || 5) + ' of them had any of shows —. These are reports, not homes.';
const source = e.stale ? ' Source is stale: last read ' + age(e.observed_at) + '. These are the last saved values.' : ' Source: FTW Cloud.';
if (e.state === 'pending') setText('energy-note', 'Daily energy reports appear once FTW Cloud publishes them. ' + rule);
if (e.state === 'withheld') setText('energy-note', 'No public day yet. ' + rule + source);
if (e.state === 'connected' && d) setText('energy-note', 'Newest public day ' + d.date + '. ' + rule + source);
}

function renderRepositories(repositories, coverage) {
const body = $('repositories');
body.replaceChildren();
Expand Down Expand Up @@ -516,6 +551,7 @@ export function dashboardDocument(privateView: boolean): DashboardDocument {
renderFreshness(data.freshness || {}, data.mode === 'private');
renderOverview(data);
renderUsage(data.usage, data.installation_estimate);
renderEnergyReports(data.energy_reports);
renderHeadline(data.github || {});
renderRepositories(data.github && data.github.repositories, data.coverage);
renderLine('star-chart', aggregateStateHistory(data.github && data.github.history, 'stars', data.github && data.github.repositories));
Expand Down Expand Up @@ -551,6 +587,7 @@ const styles = `
[hidden]{display:none!important}
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% -20%,#252116 0,transparent 34rem),var(--bg);min-height:100vh}a{color:inherit}.topbar{height:58px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 clamp(18px,4vw,56px);position:sticky;top:0;background:color-mix(in srgb,var(--bg) 90%,transparent);backdrop-filter:blur(16px);z-index:5}.brand{display:flex;gap:10px;align-items:center;text-decoration:none;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.18em}.brand-mark{color:var(--accent)}.brand-slash{color:#5e615d}.top-actions{display:flex;align-items:center;gap:16px;font:11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em}.mode{color:var(--accent);border:1px solid #59401c;padding:6px 8px;border-radius:2px}.muted{color:var(--dim)}main{width:min(1280px,calc(100% - 36px));margin:0 auto;padding:64px 0 72px}.hero{display:flex;justify-content:space-between;gap:48px;align-items:flex-end;margin-bottom:54px}.eyebrow{color:var(--accent);font:700 10px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.2em;margin:0 0 12px}h1{font-size:clamp(36px,5vw,72px);line-height:.98;letter-spacing:-.055em;max-width:800px;margin:0}h2{font-size:20px;letter-spacing:-.025em;margin:0}h3{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;margin:0 0 10px}.lede{color:var(--dim);font-size:17px;max-width:660px;line-height:1.5;margin:20px 0 0}.freshness{min-width:230px;display:grid;gap:8px}.freshness-item{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;font-size:12px;color:var(--dim)}.freshness-item strong{font-weight:500;color:var(--fg)}.dot{width:7px;height:7px;border-radius:50%;background:#555;display:inline-block}.dot.ok{background:var(--ok);box-shadow:0 0 12px color-mix(in srgb,var(--ok) 50%,transparent)}.dot.warn{background:var(--warn)}.dot.idle{background:#555}.notice{border:1px solid #674a20;background:#21190e;color:#ffd48f;padding:14px 16px;margin-bottom:24px}.section-head,.panel-head{display:flex;justify-content:space-between;align-items:flex-end;gap:20px}.section-head{margin:0 0 16px}.section-copy{color:var(--dim);font-size:12px;line-height:1.5;max-width:560px;margin:0;text-align:right}.overview-grid,.metric-grid{display:grid;border-top:1px solid var(--line);border-left:1px solid var(--line);margin-bottom:28px}.overview-grid{grid-template-columns:repeat(5,minmax(0,1fr))}.metric-grid{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:18px}.metric-card{background:color-mix(in srgb,var(--surface) 92%,transparent);min-height:154px;padding:22px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);display:flex;flex-direction:column}.overview-card{min-height:142px}.metric-card>span,.mini-grid span{font:11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}.metric-card strong{font-size:42px;line-height:1;margin:auto 0 12px;letter-spacing:-.05em}.metric-card.status-card strong{font-size:30px;letter-spacing:-.035em}.metric-card small{color:var(--dim);font-size:12px;line-height:1.35}.two-col{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:18px 0}.panel{background:color-mix(in srgb,var(--surface) 94%,transparent);border:1px solid var(--line);padding:22px;margin:18px 0}.two-col>.panel{margin:0}.chart-panel{min-height:330px}.chart{height:235px;margin-top:24px}.chart svg{display:block;width:100%;height:100%;overflow:visible}h2{scroll-margin-top:90px}.mini-grid.usage-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.chart-panel .muted{font-size:11px}.chart-area{fill:color-mix(in srgb,var(--accent) 9%,transparent)}.chart-line{fill:none;stroke:var(--accent);stroke-width:3;stroke-linejoin:round;stroke-linecap:round}.bars{height:230px;display:flex;gap:4px;align-items:flex-end;margin-top:24px;border-bottom:1px solid var(--line);padding:0 2px}.bars.compact{height:150px}.bar-wrap{height:100%;flex:1;min-width:3px;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:8px}.bar{width:100%;max-width:28px;background:linear-gradient(to top,#88520d,var(--accent));border-radius:2px 2px 0 0;min-height:2px}.bar.withheld{background:#4a4c49}.bar.h0{height:2%}.bar.h1{height:5%}.bar.h2{height:10%}.bar.h3{height:15%}.bar.h4{height:20%}.bar.h5{height:25%}.bar.h6{height:30%}.bar.h7{height:35%}.bar.h8{height:40%}.bar.h9{height:45%}.bar.h10{height:50%}.bar.h11{height:55%}.bar.h12{height:60%}.bar.h13{height:65%}.bar.h14{height:70%}.bar.h15{height:75%}.bar.h16{height:80%}.bar.h17{height:85%}.bar.h18{height:90%}.bar.h19{height:95%}.bar.h20{height:100%}.bar-wrap span{font-size:8px;color:var(--dim);height:12px;white-space:nowrap}.table-wrap{overflow-x:auto;margin-top:20px}table{width:100%;border-collapse:collapse;font-size:13px}th{text-align:left;color:var(--dim);font:10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase;letter-spacing:.08em;padding:12px 14px;border-bottom:1px solid var(--line)}td{padding:15px 14px;border-bottom:1px solid #232523;white-space:nowrap}td:first-child{font-weight:650}td a{text-decoration-color:#51544f;text-underline-offset:3px}.repo-role{display:block;color:var(--dim);font-size:11px;font-weight:400;margin-top:4px}.numeric{font-variant-numeric:tabular-nums}.empty{color:var(--dim);padding:30px 4px;text-align:center}.panel-copy,.footnote{color:var(--dim);font-size:13px;line-height:1.5}.big-inline{font-size:28px;font-weight:700;letter-spacing:-.04em}.dimension-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:26px}.dimension{border-top:1px solid var(--line);padding-top:14px}.chips{display:flex;gap:7px;flex-wrap:wrap}.chip{font:11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;color:#d5d3cb;background:var(--raised);border:1px solid var(--line);padding:6px 8px;border-radius:2px}.mini-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);margin-top:22px}.mini-grid>div{background:var(--raised);padding:18px;display:flex;flex-direction:column;gap:12px}.mini-grid strong{font-size:26px}.ranked-list,.status-list{list-style:none;padding:0;margin:18px 0 0}.ranked-list li{display:flex;justify-content:space-between;gap:18px;padding:12px 0;border-bottom:1px solid var(--line);font-size:13px}.status-list li{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;padding:12px 0;border-bottom:1px solid var(--line);font-size:12px}footer{border-top:1px solid var(--line);padding:24px clamp(18px,4vw,56px);display:flex;justify-content:space-between;gap:20px;color:var(--dim);font-size:12px}footer a{color:var(--fg);text-underline-offset:3px}
.mini-grid.site-grid{grid-template-columns:repeat(3,1fr)}
.usage-subhead{margin:28px 0 0}
@media(max-width:1100px){.overview-grid{grid-template-columns:repeat(3,1fr)}}
@media(max-width:900px){.hero{align-items:flex-start;flex-direction:column}.freshness{width:100%;grid-template-columns:repeat(2,1fr)}.section-head{align-items:flex-start;flex-direction:column}.section-copy{text-align:left}.overview-grid,.metric-grid{grid-template-columns:repeat(2,1fr)}.two-col{grid-template-columns:1fr}.dimension-grid{grid-template-columns:repeat(2,1fr)}.mini-grid,.mini-grid.usage-grid{grid-template-columns:repeat(2,1fr)}.mini-grid.site-grid{grid-template-columns:repeat(3,1fr)}}
@media(max-width:560px){main{width:min(100% - 24px,1280px);padding-top:38px}.topbar{padding:0 12px}.top-actions #generated{display:none}.hero{margin-bottom:36px;gap:28px}h1{font-size:40px}.freshness{grid-template-columns:1fr}.metric-card{min-height:130px;padding:16px}.metric-card strong{font-size:34px}.panel{padding:16px}.dimension-grid{grid-template-columns:1fr}.mini-grid{grid-template-columns:1fr 1fr}.bars{gap:2px}footer{flex-direction:column;padding:20px 12px}}
Expand Down
Loading
Loading