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..86d8e6f 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,21 +164,21 @@ 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: 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'], @@ -768,6 +768,16 @@ describe('installation estimates',()=>{ expect(()=>parseFleet(v,now)).toThrow() expect(()=>parseFleet({...fleetView(now),home:'secret'},now)).toThrow() }) + 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) + 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/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"; 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"