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-fleet-minimum-5.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 7 additions & 3 deletions stats/src/fleet.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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<string, unknown>, 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)<nowMs-91*86400_000 || seen.has(date)) throw new Error('invalid fleet day')
seen.add(date)
const reports = d['reports']
if (reports !== null && (!Number.isSafeInteger(reports) || (reports as number)<10 || (reports as number)>100000)) throw new Error('small fleet count')
if (reports !== null && (!Number.isSafeInteger(reports) || (reports as number)<minimum || (reports as number)>100000)) throw new Error('small fleet count')
for (const key of dimensions) {
const labels=d[key] as Record<string,unknown>
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)<minimum || (n as number)>(reports as number)) throw new Error('small fleet label')
}
}
return v as unknown as PublicFleetFeed
Expand Down
2 changes: 1 addition & 1 deletion stats/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): string | null {
Expand Down
28 changes: 19 additions & 9 deletions stats/test/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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<Record<string, any>>()
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')
Expand All @@ -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<Record<string, any>>()
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'],
Expand Down Expand Up @@ -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))))
Expand Down
4 changes: 2 additions & 2 deletions stats/worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 1 addition & 1 deletion stats/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading