-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
chore: add function to generate some of the metrics report #261
Changes from all commits
8f51cb3
ea4d36b
852696b
2148eb6
028fd31
1b07286
dd48f8a
9b3b7c0
c6be498
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
import * as path from 'node:path'; | ||
import * as fs from 'node:fs'; | ||
import generateMetricsReport from './generateMetricsReport'; | ||
|
||
describe('generateMetricsReport', () => { | ||
const packageJson = readPackageJson(); | ||
|
||
const defaultOptions: Parameters<typeof generateMetricsReport>[0] = { | ||
packageJson, | ||
os: 'android', | ||
osVersion: 123, | ||
screen: {width: 12, height: 34}, | ||
observations: [ | ||
// Middle of the Atlantic | ||
{lat: 10, lon: -33}, | ||
// Mexico City | ||
{lat: 19.419914, lon: -99.088059}, | ||
// Machias Seal Island, disputed territory | ||
{lat: 44.5, lon: -67.101111}, | ||
// To be ignored | ||
{}, | ||
{lat: 12}, | ||
{lon: 34}, | ||
], | ||
}; | ||
|
||
it('can be serialized and deserialized as JSON', () => { | ||
const report = generateMetricsReport(defaultOptions); | ||
const actual = JSON.parse(JSON.stringify(report)); | ||
const expected = removeUndefinedEntries(report); | ||
expect(actual).toEqual(expected); | ||
}); | ||
|
||
it('includes a report type', () => { | ||
const report = generateMetricsReport(defaultOptions); | ||
expect(report.type).toBe('metrics-v1'); | ||
}); | ||
|
||
it('includes the app version', () => { | ||
const report = generateMetricsReport(defaultOptions); | ||
expect(report.appVersion).toBe(packageJson.version); | ||
}); | ||
|
||
it('includes the OS (Android style)', () => { | ||
const report = generateMetricsReport(defaultOptions); | ||
expect(report.os).toBe('android'); | ||
expect(report.osVersion).toBe(123); | ||
}); | ||
|
||
it('includes the OS (iOS style)', () => { | ||
const options = {...defaultOptions, os: 'ios' as const, osVersion: '1.2.3'}; | ||
const report = generateMetricsReport(options); | ||
expect(report.os).toBe('ios'); | ||
expect(report.osVersion).toBe('1.2.3'); | ||
}); | ||
|
||
it('includes the OS (desktop style)', () => { | ||
const options = { | ||
...defaultOptions, | ||
os: 'win32' as const, | ||
osVersion: '1.2.3', | ||
}; | ||
const report = generateMetricsReport(options); | ||
expect(report.os).toBe('win32'); | ||
expect(report.osVersion).toBe('1.2.3'); | ||
}); | ||
|
||
it('includes screen dimensions', () => { | ||
const report = generateMetricsReport(defaultOptions); | ||
expect(report.screen).toEqual({width: 12, height: 34}); | ||
}); | ||
|
||
it("doesn't include countries if no observations are provided", () => { | ||
const options = {...defaultOptions, observations: []}; | ||
const report = generateMetricsReport(options); | ||
expect(report.countries).toBe(undefined); | ||
}); | ||
|
||
it('includes countries where observations are found', () => { | ||
const report = generateMetricsReport(defaultOptions); | ||
expect(report.countries).toHaveLength(new Set(report.countries).size); | ||
expect(new Set(report.countries)).toEqual(new Set(['MEX', 'CAN', 'USA'])); | ||
}); | ||
}); | ||
|
||
function readPackageJson() { | ||
const packageJsonPath = path.resolve( | ||
__dirname, | ||
'..', | ||
'..', | ||
'..', | ||
'package.json', | ||
); | ||
const packageJsonData = fs.readFileSync(packageJsonPath, 'utf8'); | ||
return JSON.parse(packageJsonData); | ||
} | ||
|
||
function removeUndefinedEntries( | ||
obj: Record<string, unknown>, | ||
): Record<string, unknown> { | ||
const definedEntries = Object.entries(obj).filter( | ||
entry => entry[1] !== undefined, | ||
); | ||
return Object.fromEntries(definedEntries); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import type {ReadonlyDeep} from 'type-fest'; | ||
import type {Observation} from '@mapeo/schema'; | ||
import positionToCountries from './positionToCountries'; | ||
|
||
export default function generateMetricsReport({ | ||
packageJson, | ||
os, | ||
osVersion, | ||
screen, | ||
observations, | ||
}: ReadonlyDeep<{ | ||
packageJson: {version: string}; | ||
os: 'android' | 'ios' | NodeJS.Platform; | ||
osVersion: number | string; | ||
screen: {width: number; height: number}; | ||
observations: ReadonlyArray<Pick<Observation, 'lat' | 'lon'>>; | ||
}>) { | ||
const countries = new Set<string>(); | ||
|
||
for (const {lat, lon} of observations) { | ||
if (typeof lat === 'number' && typeof lon === 'number') { | ||
addToSet(countries, positionToCountries(lat, lon)); | ||
} | ||
} | ||
|
||
return { | ||
type: 'metrics-v1', | ||
appVersion: packageJson.version, | ||
os, | ||
osVersion, | ||
screen, | ||
...(countries.size ? {countries: Array.from(countries)} : {}), | ||
}; | ||
} | ||
|
||
function addToSet<T>(set: Set<T>, toAdd: Iterable<T>): void { | ||
for (const item of toAdd) set.add(item); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
import positionToCountries from './positionToCountries'; | ||
|
||
describe('positionToCountries', () => { | ||
it('returns nothing for invalid values', () => { | ||
expect(positionToCountries(-91, 181)).toEqual(new Set()); | ||
expect(positionToCountries(Infinity, -Infinity)).toEqual(new Set()); | ||
expect(positionToCountries(NaN, NaN)).toEqual(new Set()); | ||
}); | ||
|
||
it('returns nothing for the middle of the Atlantic ocean', () => { | ||
expect(positionToCountries(10, -33)).toEqual(new Set()); | ||
}); | ||
|
||
it('returns Mexico for a point in Mexico City', () => { | ||
expect(positionToCountries(19.419914, -99.088059)).toEqual( | ||
new Set(['MEX']), | ||
); | ||
}); | ||
|
||
it('returns multiple countries for disputed territories', () => { | ||
// [Machias Seal Island][0] is a disputed territory. | ||
// [0]: https://en.wikipedia.org/wiki/Machias_Seal_Island | ||
expect(positionToCountries(44.5, -67.101111)).toEqual( | ||
new Set(['CAN', 'USA']), | ||
); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import borders from '@osm_borders/maritime_10000m'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This data is accurate within 10 kilometers. There are other choices with higher resolution but (1) this is small and therefore the best for performance1 (2) high accuracy might violate user privacy. Footnotes
|
||
import GeojsonGeometriesLookup from 'geojson-geometries-lookup'; | ||
|
||
let lookup: undefined | GeojsonGeometriesLookup; | ||
|
||
export default function positionToCountries( | ||
latitude: number, | ||
longitude: number, | ||
): Set<string> { | ||
lookup ??= new GeojsonGeometriesLookup(borders); | ||
|
||
const result = new Set<string>(); | ||
|
||
const {features} = lookup.getContainers({ | ||
type: 'Point', | ||
coordinates: [longitude, latitude], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I thought this was backwards, but I believe it's right (and this function is unit tested). |
||
}); | ||
for (const {properties} of features) { | ||
if ( | ||
properties && | ||
'isoA3' in properties && | ||
typeof properties.isoA3 === 'string' | ||
) { | ||
result.add(properties.isoA3); | ||
} | ||
} | ||
|
||
return result; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
declare module 'geojson-geometries-lookup' { | ||
import type { | ||
GeoJSON, | ||
Point, | ||
LineString, | ||
Polygon, | ||
FeatureCollection, | ||
} from '@types/geojson'; | ||
|
||
export default class GeojsonGeometriesLookup { | ||
constructor(geoJson: GeoJSON); | ||
|
||
getContainers(geometry: Point | LineString | Polygon): FeatureCollection; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
declare module '@osm_borders/maritime_10000m' { | ||
import {GeoJSON} from 'geojson'; | ||
|
||
const data: GeoJSON; | ||
export default data; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function takes a bunch of dependencies. It's more verbose, but I think it has some advantages: