From 8c155d6b0bfc5b96e423890c1355a68707b964b3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 15:57:28 +0200 Subject: [PATCH 1/5] fix(stats): take FTW Cloud's group size of five for daily reports FTW Cloud lowers its public group size for anonymous daily reports from 10 to 5. The Worker accepted only 10, so it would refuse the new feed. It now accepts 5 or 10, holds each day and label to the size the feed names, and shows totals from 5 reports (PUBLIC_MIN_REPORTS). Co-Authored-By: Claude Opus 5.5 --- .changeset/stats-fleet-minimum-5.md | 5 +++++ stats/src/fleet.ts | 10 +++++++--- stats/src/store.ts | 2 +- stats/test/worker.test.ts | 10 ++++++++++ stats/wrangler.jsonc | 2 +- 5 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 .changeset/stats-fleet-minimum-5.md diff --git a/.changeset/stats-fleet-minimum-5.md b/.changeset/stats-fleet-minimum-5.md new file mode 100644 index 0000000..2889941 --- /dev/null +++ b/.changeset/stats-fleet-minimum-5.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +The project stats page counts FTW Cloud's daily reports once five boxes report a day, not ten. diff --git a/stats/src/fleet.ts b/stats/src/fleet.ts index 11b00ce..9d9b811 100644 --- a/stats/src/fleet.ts +++ b/stats/src/fleet.ts @@ -1,25 +1,29 @@ import { saveCollectorRun } from './collector.ts' import type { AppEnv } from './types.ts' +// FTW Cloud's own group size for a public day. 5 since 23 October 2026; +// 10 before, kept while an older Cloud may still answer. +const PUBLIC_FLEET_MINIMUMS = [5, 10] const dimensions = ['ftw_versions', 'channels', 'drivers', 'battery_kwh', 'price_zones', 'install_age'] as const export interface PublicFleetDay { date: string; reports: number | null } export interface PublicFleetFeed { schema: 'ftw.fleet-public/1'; minimum: number; meaning: string; observed_at: string; days: PublicFleetDay[] } export function parseFleet(value: unknown, nowMs: number): PublicFleetFeed { const v = value as Record - if (!v || typeof v !== 'object' || Array.isArray(v) || Object.keys(v).sort().join() !== ['days','meaning','minimum','observed_at','schema'].join() || v['schema'] !== 'ftw.fleet-public/1' || v['minimum'] !== 10 || v['meaning'] !== 'reports, not unique boxes') throw new Error('invalid public fleet') + if (!v || typeof v !== 'object' || Array.isArray(v) || Object.keys(v).sort().join() !== ['days','meaning','minimum','observed_at','schema'].join() || v['schema'] !== 'ftw.fleet-public/1' || !PUBLIC_FLEET_MINIMUMS.includes(v['minimum'] as number) || v['meaning'] !== 'reports, not unique boxes') throw new Error('invalid public fleet') const observed = Date.parse(String(v['observed_at'])) if (!Number.isFinite(observed) || Math.abs(nowMs-observed)>2*3600_000 || !Array.isArray(v['days']) || v['days'].length>90) throw new Error('invalid fleet window') + const minimum = v['minimum'] as number const seen = new Set() for (const item of v['days']) { const d = item as Record, date = String(d['date']) if (!d || Object.keys(d).sort().join() !== ['date','reports',...dimensions].sort().join() || !/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(date)) || new Date(date).toISOString().slice(0,10)!==date || date>new Date(nowMs).toISOString().slice(0,10) || Date.parse(date)100000)) throw new Error('small fleet count') + if (reports !== null && (!Number.isSafeInteger(reports) || (reports as number)100000)) throw new Error('small fleet count') for (const key of dimensions) { const labels=d[key] as Record if (!labels || typeof labels!=='object' || Array.isArray(labels) || Object.keys(labels).length>128) throw new Error('invalid fleet labels') - for (const [name,n] of Object.entries(labels)) if (reports===null || !name || name.length>128 || /[\x00-\x1f\x7f]/.test(name) || !Number.isSafeInteger(n) || (n as number)<10 || (n as number)>(reports as number)) throw new Error('small fleet label') + for (const [name,n] of Object.entries(labels)) if (reports===null || !name || name.length>128 || /[\x00-\x1f\x7f]/.test(name) || !Number.isSafeInteger(n) || (n as number)(reports as number)) throw new Error('small fleet label') } } return v as unknown as PublicFleetFeed diff --git a/stats/src/store.ts b/stats/src/store.ts index f1e502d..cf7dcf1 100644 --- a/stats/src/store.ts +++ b/stats/src/store.ts @@ -615,7 +615,7 @@ function byteCountBand(value: number): string { function publicMinimum(value: string): number { const parsed = Number(value) - return Number.isSafeInteger(parsed) && parsed >= 10 && parsed <= 1000 ? parsed : 10 + return Number.isSafeInteger(parsed) && parsed >= 5 && parsed <= 1000 ? parsed : 10 } function latestTimestamp(values: Array): string | null { diff --git a/stats/test/worker.test.ts b/stats/test/worker.test.ts index 48f790a..0af2284 100644 --- a/stats/test/worker.test.ts +++ b/stats/test/worker.test.ts @@ -768,6 +768,16 @@ describe('installation estimates',()=>{ expect(()=>parseFleet(v,now)).toThrow() expect(()=>parseFleet({...fleetView(now),home:'secret'},now)).toThrow() }) + it('takes FTW Cloud's group size of 5 and holds each day and label to it',()=>{ + const now=Date.UTC(2026,9,23,13);const v={...fleetView(now),minimum:5} + v.days[0]!.reports=5;v.days[0]!.drivers={easee_cloud:5} + expect(parseFleet(v,now)).toEqual(v) + v.days[0]!.drivers={easee_cloud:4} + expect(()=>parseFleet(v,now)).toThrow('small fleet label') + v.days[0]!.drivers={};v.days[0]!.reports=4 + expect(()=>parseFleet(v,now)).toThrow('small fleet count') + expect(()=>parseFleet({...fleetView(now),minimum:3},now)).toThrow('invalid public fleet') + }) it('collects the safe Cloud feed independently of hourly energy approval',async()=>{ const now=Date.UTC(2026,9,17,13) vi.stubGlobal('fetch',vi.fn().mockResolvedValue(Response.json(fleetView(now)))) diff --git a/stats/wrangler.jsonc b/stats/wrangler.jsonc index b3268d1..490d93e 100644 --- a/stats/wrangler.jsonc +++ b/stats/wrangler.jsonc @@ -15,7 +15,7 @@ "GITHUB_OWNER": "srcfl", "GITHUB_REPOS": "ftw,device-drivers,ftw-cloud,ftw-app,ftw-webapp,ftw-web", "SITE_HOSTNAME": "app.ftw.energy", - "PUBLIC_MIN_REPORTS": "10", + "PUBLIC_MIN_REPORTS": "5", "CLOUDFLARE_ZONE_ID": "b8e2cf4b19abbeb535cca652750916c9", "ACCESS_TEAM_DOMAIN": "https://sourceful.cloudflareaccess.com", "ACCESS_AUD": "980a936eae5215f4484780b68996a4985f435a0e19cd8d11c13225b3f74844cd" From 3f3e8cf0c6efdefced1239794eebe63a5183f911 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 15:59:29 +0200 Subject: [PATCH 2/5] test(stats): name the five-report case without a quote Co-Authored-By: Claude Opus 5.5 --- stats/test/worker.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stats/test/worker.test.ts b/stats/test/worker.test.ts index 0af2284..172e824 100644 --- a/stats/test/worker.test.ts +++ b/stats/test/worker.test.ts @@ -768,7 +768,7 @@ describe('installation estimates',()=>{ expect(()=>parseFleet(v,now)).toThrow() expect(()=>parseFleet({...fleetView(now),home:'secret'},now)).toThrow() }) - it('takes FTW Cloud's group size of 5 and holds each day and label to it',()=>{ + it('takes a Cloud group size of 5 and holds each day and label to it',()=>{ const now=Date.UTC(2026,9,23,13);const v={...fleetView(now),minimum:5} v.days[0]!.reports=5;v.days[0]!.drivers={easee_cloud:5} expect(parseFleet(v,now)).toEqual(v) From 4338945db752aaa662802e87522fe2f10bec0609 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 16:04:10 +0200 Subject: [PATCH 3/5] chore(stats): regenerate the Worker types for PUBLIC_MIN_REPORTS 5 Co-Authored-By: Claude Opus 5.5 --- stats/worker-configuration.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stats/worker-configuration.d.ts b/stats/worker-configuration.d.ts index d39d811..1b4f4fc 100644 --- a/stats/worker-configuration.d.ts +++ b/stats/worker-configuration.d.ts @@ -1,11 +1,11 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --config=stats/wrangler.jsonc --include-runtime=false stats/worker-configuration.d.ts` (hash: 5c71bd7b19b04bb9824ffb139cc2964c) +// Generated by Wrangler by running `wrangler types --config=stats/wrangler.jsonc --include-runtime=false stats/worker-configuration.d.ts` (hash: 0eef6eb84779d2e6523fd38951946019) interface __BaseEnv_Env { DB: D1Database; GITHUB_OWNER: "srcfl"; GITHUB_REPOS: "ftw,device-drivers,ftw-cloud,ftw-app,ftw-webapp,ftw-web"; SITE_HOSTNAME: "app.ftw.energy"; - PUBLIC_MIN_REPORTS: "10"; + PUBLIC_MIN_REPORTS: "5"; CLOUDFLARE_ZONE_ID: "b8e2cf4b19abbeb535cca652750916c9"; ACCESS_TEAM_DOMAIN: "https://sourceful.cloudflareaccess.com"; ACCESS_AUD: "980a936eae5215f4484780b68996a4985f435a0e19cd8d11c13225b3f74844cd"; From 20c700ae96f1f569604055b6ce79ecdf8e8744aa Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 16:10:23 +0200 Subject: [PATCH 4/5] test(stats): hold the Worker's fleet tests to a minimum of five Co-Authored-By: Claude Opus 5.5 --- stats/test/worker.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/stats/test/worker.test.ts b/stats/test/worker.test.ts index 172e824..be6812f 100644 --- a/stats/test/worker.test.ts +++ b/stats/test/worker.test.ts @@ -47,7 +47,7 @@ describe('project stats Worker', () => { expect(data.fleet).toMatchObject({ state: 'empty', reports_30d: null, - minimum: 10, + minimum: 5, observed: { ftw_versions: [], drivers: [] }, }) expect(data.relay_status).toEqual({ @@ -140,7 +140,7 @@ describe('project stats Worker', () => { const publicResponse = await worker.fetch(new Request('https://stats.ftw.energy/api/public'), env) const data = await publicResponse.json>() - expect(data.fleet).toMatchObject({ state: 'withheld', reports_30d: null, minimum: 10 }) + expect(data.fleet).toMatchObject({ state: 'withheld', reports_30d: null, minimum: 5 }) expect(data.fleet.observed).toEqual({ ftw_versions: [], drivers: [] }) expect(data.fleet.dimensions).toEqual({}) expect(JSON.stringify(data.fleet)).not.toContain('v1.16.1-beta.22') @@ -164,12 +164,12 @@ describe('project stats Worker', () => { expect(data.freshness.fleet).toBe(now.toISOString()) }) - it('shows only version and driver labels whose own count reaches ten', async () => { + it('shows only version and driver labels whose own count reaches the minimum', async () => { const now = new Date() const payload = relayPayload(now, 20) - payload.fleet.days[0]!.channels = { beta: 9, unknown: 11 } - payload.fleet.days[0]!.ftw_versions = { 'common-version': 10, 'rare-version': 9, 'other-version': 1 } - payload.fleet.days[0]!.drivers = { easee_cloud: 10, solis: 9, modbus: 1 } + payload.fleet.days[0]!.channels = { beta: 4, unknown: 11 } + payload.fleet.days[0]!.ftw_versions = { 'common-version': 10, 'rare-version': 4, 'other-version': 1 } + payload.fleet.days[0]!.drivers = { easee_cloud: 10, solis: 4, modbus: 1 } await sendRelay(payload, now) const response = await worker.fetch(new Request('https://stats.ftw.energy/api/public'), env) From 5422ddadbcdfc2f98d5cc53d35c3c8d21781ba1a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 16:25:46 +0200 Subject: [PATCH 5/5] test(stats): keep each label total equal to the day's reports Co-Authored-By: Claude Opus 5.5 --- stats/test/worker.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/stats/test/worker.test.ts b/stats/test/worker.test.ts index be6812f..86d8e6f 100644 --- a/stats/test/worker.test.ts +++ b/stats/test/worker.test.ts @@ -167,18 +167,18 @@ describe('project stats Worker', () => { it('shows only version and driver labels whose own count reaches the minimum', async () => { const now = new Date() const payload = relayPayload(now, 20) - payload.fleet.days[0]!.channels = { beta: 4, unknown: 11 } - payload.fleet.days[0]!.ftw_versions = { 'common-version': 10, 'rare-version': 4, 'other-version': 1 } - payload.fleet.days[0]!.drivers = { easee_cloud: 10, solis: 4, modbus: 1 } + payload.fleet.days[0]!.channels = { beta: 4, unknown: 16 } + payload.fleet.days[0]!.ftw_versions = { 'common-version': 15, 'rare-version': 4, 'other-version': 1 } + payload.fleet.days[0]!.drivers = { easee_cloud: 15, solis: 4, modbus: 1 } await sendRelay(payload, now) const response = await worker.fetch(new Request('https://stats.ftw.energy/api/public'), env) const data = await response.json>() expect(data.fleet.reports_30d).toBe(20) expect(data.fleet.days[0].reports).toBe(20) - expect(data.fleet.dimensions.channels).toEqual({ unknown: 11 }) - expect(data.fleet.dimensions.ftw_versions).toEqual({ 'common-version': 10 }) - expect(data.fleet.dimensions.drivers).toEqual({ easee_cloud: 10 }) + expect(data.fleet.dimensions.channels).toEqual({ unknown: 16 }) + expect(data.fleet.dimensions.ftw_versions).toEqual({ 'common-version': 15 }) + expect(data.fleet.dimensions.drivers).toEqual({ easee_cloud: 15 }) expect(data.fleet.observed).toEqual({ ftw_versions: ['common-version'], drivers: ['easee_cloud'],