diff --git a/app/constants/history.js b/app/constants/history.js index 5c9b6286d1..d488a3b57f 100644 --- a/app/constants/history.js +++ b/app/constants/history.js @@ -1,12 +1,18 @@ /** * Max esposure reporting window in days */ -export const MAX_EXPOSURE_WINDOW = 14; +export const MAX_EXPOSURE_WINDOW_DAYS = 14; /** * The value in minutes of each "bin" in the crossed path data. */ -export const BIN_DURATION = 5; +export const DEFAULT_EXPOSURE_PERIOD_MINUTES = 5; + +/** + * The value in minutes of how long an exposure at a location is + * considered concerning. + */ +export const CONCERN_TIME_WINDOW_MINUTES = 4 * 60; // 4 hours, in minutes /** * Format of a single history item diff --git a/app/helpers/Intersect.js b/app/helpers/Intersect.js index 9dc4c1b9fe..4252a9fc88 100644 --- a/app/helpers/Intersect.js +++ b/app/helpers/Intersect.js @@ -4,9 +4,16 @@ * v1 - Unencrypted, simpleminded (minimal optimization). */ +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; import PushNotification from 'react-native-push-notification'; import { isPlatformiOS } from './../Util'; +import { + CONCERN_TIME_WINDOW_MINUTES, + DEFAULT_EXPOSURE_PERIOD_MINUTES, + MAX_EXPOSURE_WINDOW_DAYS, +} from '../constants/history'; import { AUTHORITY_NEWS, AUTHORITY_SOURCE_SETTINGS, @@ -18,91 +25,109 @@ import { DEBUG_MODE } from '../constants/storage'; import { GetStoreData, SetStoreData } from '../helpers/General'; import languages from '../locales/languages'; -export async function IntersectSet(concernLocationArray, completion) { - GetStoreData(LOCATION_DATA).then(locationArrayString => { - let locationArray; - if (locationArrayString !== null) { - locationArray = JSON.parse(locationArrayString); - } else { - locationArray = []; +/** + * Intersects the locationArray with the concernLocationArray, returning the results + * as a dayBin array. + * + * @param {array} localArray - array of the local locations. Assumed to have been sorted and normalized + * @param {array} concernArray - superset array of all concerning points from health authorities. Assumed to have been sorted and normalized + * @param {int} numDayBins - (optional) number of bins in the array returned + * @param {int} concernTimeWindowMS - (optional) window of time to use when determining an exposure + * @param {int} defaultExposurePeriodMS - (optional) the default exposure period to use when necessary when an exposure is found + */ +export function intersectSetIntoBins( + localArray, + concernArray, + numDayBins = MAX_EXPOSURE_WINDOW_DAYS, + concernTimeWindowMS = 1000 * 60 * CONCERN_TIME_WINDOW_MINUTES, + defaultExposurePeriodMS = DEFAULT_EXPOSURE_PERIOD_MINUTES * 60 * 1000, +) { + // useful for time calcs + dayjs.extend(duration); + + // generate an array with the asked for number of day bins + const dayBins = getEmptyLocationBins(numDayBins); + + //for (let loc of localArray) { + for (let i = 0; i < localArray.length; i++) { + let currentLocation = localArray[i]; + + // The current day is 0 days ago (in otherwords, bin 0). + // Figure out how many days ago the current location was. + // Note that we're basing this off midnight in the user's current timezone. + // Also using the dayjs subtract method, which should take timezone and + // daylight savings into account. + let midnight = dayjs().startOf('day'); + let daysAgo = 0; + while (currentLocation.time < midnight.valueOf() && daysAgo < numDayBins) { + midnight = midnight.subtract(1, 'day'); + daysAgo++; } - let dayBin = [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - ]; // Bins for 28 days - - // Sort the concernLocationArray - let localArray = normalizeData(locationArray); - let concernArray = normalizeData(concernLocationArray); - - let concernTimeWindow = 1000 * 60 * 60 * 2; // +/- 2 hours window - - let nowUTC = new Date().toISOString(); - let timeNow = Date.parse(nowUTC); - - // Both locationArray and concernLocationArray should be in the - // format [ { "time": 123, "latitude": 12.34, "longitude": 34.56 }] - - for (let loc of localArray) { - let timeMin = loc.time - concernTimeWindow; - let timeMax = loc.time + concernTimeWindow; - - let i = binarySearchForTime(concernArray, timeMin); - if (i < 0) i = -(i + 1); - - while (i < concernArray.length && concernArray[i].time <= timeMax) { - if ( - isLocationsNearby( - concernArray[i].latitude, - concernArray[i].longitude, - loc.latitude, - loc.longitude, - ) - ) { - // Crossed path. Bin the count of encounters by days from today. - let longAgo = timeNow - loc.time; - let daysAgo = Math.round(longAgo / (1000 * 60 * 60 * 24)); - - dayBin[daysAgo] += 1; + // if this location's date is earlier than the number of days to bin, we can skip it + if (daysAgo >= numDayBins) continue; + + // Check to see if this is the first exposure for this bin. If so, reset the exposure time to 0 + // to indicate that we do actually have some data for this day + if (dayBins[daysAgo] < 0) dayBins[daysAgo] = 0; + + // timeMin and timeMax set from the concern time window + // These define the window of time that is considered an intersection of concern. + // The idea is that if this location (lat,lon) is in the concernLocationArray from + // the time starting from this location's recorded minus the concernTimeWindow time up + // to this locations recorded time, then it is a location of concern. + let timeMin = currentLocation.time - concernTimeWindowMS; + let timeMax = currentLocation.time; + + // now find the index in the concernArray that starts with timeMin (if one exists) + // + // TODO: There's probably an optimization that could be done if the locationArray + // increased in time only a small amount, since the index we found + // in the concernArray is probably already close to where we want to be. + let j = binarySearchForTime(concernArray, timeMin); + // we don't really if the exact timestamp wasn't found, so just take the j value as the index to start + if (j < 0) j = -(j + 1); + + // starting at the now known index that corresponds to the beginning of the + // location time window, go through all of the concernArray's time-locations + // to see if there are actually any intersections of concern. Stop when + // we get past the timewindow. + while (j < concernArray.length && concernArray[j].time <= timeMax) { + if ( + areLocationsNearby( + concernArray[j].latitude, + concernArray[j].longitude, + currentLocation.latitude, + currentLocation.longitude, + ) + ) { + // Crossed path. Add the exposure time to the appropriate day bin. + + // How long was the possible concern time? + // = the amount of time from the current locations time to the next location time + // or = if that calculated time is not possible or too large, use the defaultExposurePeriodMS + let exposurePeriod = defaultExposurePeriodMS; + if (i < localArray.length - 1) { + let timeWindow = localArray[i + 1].time - currentLocation.time; + if (timeWindow < defaultExposurePeriodMS * 2) { + // not over 2x the default, so should be OK + exposurePeriod = timeWindow; + } } - i++; + // now add the exposure period to the bin + dayBins[daysAgo] += exposurePeriod; + + // Since we've counted the current location time period, we can now break the loop for + // this time period and go on to the next location + break; } + + j++; } + } - // TODO: Show in the UI! - console.log('Crossing results: ', dayBin); - SetStoreData(CROSSED_PATHS, dayBin); // TODO: Store per authority? - completion(dayBin); - }); + return dayBins; } /** @@ -115,7 +140,7 @@ export async function IntersectSet(concernLocationArray, completion) { * @param {number} lon2 - location 2 longitude * @return {boolean} true if the two locations meet the criteria for nearby */ -export function isLocationsNearby(lat1, lon1, lat2, lon2) { +export function areLocationsNearby(lat1, lon1, lat2, lon2) { let nearbyDistance = 20; // in meters, anything closer is "nearby" // these numbers from https://en.wikipedia.org/wiki/Decimal_degrees @@ -160,7 +185,13 @@ export function isLocationsNearby(lat1, lon1, lat2, lon2) { return false; } -function normalizeData(arr) { +/** + * Performs "safety" cleanup of the data, to help ensure that we actually have location + * data in the array. Also fixes cases with extra info or values coming in as strings. + * + * @param {array} arr - array of locations in JSON format + */ +export function normalizeAndSortLocations(arr) { // This fixes several issues that I found in different input data: // * Values stored as strings instead of numbers // * Extra info in the input @@ -184,6 +215,7 @@ function normalizeData(arr) { return result; } +// Basic binary search. Assumes a sorted array. function binarySearchForTime(array, targetTime) { // Binary search: // array = sorted array @@ -211,92 +243,132 @@ function binarySearchForTime(array, targetTime) { return -i - 1; } +/** + * Kicks off the intersection process. Immediately returns after starting the + * background intersection. Typically would be run about every 12 hours, but + * but might get run more frequently, e.g. when the user changes authority settings + * + * TODO: This call kicks off the intersection, as well as getting basic info + * from the authority (e.g. the news url) since we get that in the same call. + * Ideally those should probably be broken up better, but for now leaving it alone. + */ export function checkIntersect() { - // This function is called once every 12 hours. It should do several things: - console.log( 'Intersect tick entering on', isPlatformiOS() ? 'iOS' : 'Android', ); - // this.findNewAuthorities(); NOT IMPLEMENTED YET - - // Get the user's health authorities - GetStoreData(AUTHORITY_SOURCE_SETTINGS) - .then(authority_list => { - if (!authority_list) { - console.log('No authorities', authority_list); - return; - } - let name_news = []; - SetStoreData(AUTHORITY_NEWS, name_news); - - if (authority_list) { - // Pull down data from all the registered health authorities - authority_list = JSON.parse(authority_list); - for (const authority of authority_list) { - console.log('[auth]', authority); - fetch(authority.url) - .then(response => response.json()) - .then(responseJson => { - // Example response = - // { "authority_name": "Steve's Fake Testing Organization", - // "publish_date_utc": "1584924583", - // "info_website": "https://www.who.int/emergencies/diseases/novel-coronavirus-2019", - // "concern_points": - // [ - // { "time": 123, "latitude": 12.34, "longitude": 12.34}, - // { "time": 456, "latitude": 12.34, "longitude": 12.34} - // ] - // } - // TODO: Add an "info_exposure_url" to allow recommendations for - // the health authority driectly on the Exposure History - // page (e.g. the "What Do I Do Now?" button) - // TODO: Add an "info_newsflash" UTC timestamp and popup a - // notification if that changes, i.e. if there is a newsflash? - - // Update cache of info about the authority - GetStoreData(AUTHORITY_NEWS).then(nameNewsString => { - let name_news = []; - if (nameNewsString !== null) { - name_news = JSON.parse(nameNewsString); - } - - name_news.push({ - name: responseJson.authority_name, - news_url: responseJson.info_website, - }); - SetStoreData(AUTHORITY_NEWS, name_news); - }); - - // TODO: Look at "publish_date_utc". We should notify users if - // their authority is no longer functioning.) - - IntersectSet(responseJson.concern_points, dayBin => { - if (dayBin !== null && dayBin.reduce((a, b) => a + b, 0) > 0) { - PushNotification.localNotification({ - title: languages.t('label.push_at_risk_title'), - message: languages.t('label.push_at_risk_message'), - }); - } - }); - }); - - let nowUTC = new Date().toISOString(); - let unixtimeUTC = Date.parse(nowUTC); - // Last checked key is not being used atm. TODO check this to update periodically instead of every foreground activity - SetStoreData(LAST_CHECKED, unixtimeUTC); - } - } else { - console.log('No authority list'); - return; + asyncCheckIntersect().then(result => { + console.log('[intersect] completed: ', result); + }); +} + +/** + * Async run of the intersection. Also saves off the news sources that the authories specified, + * since that comes from the authorities in the same download. + * + * Returns the array of day bins (mostly for debugging purposes) + */ +async function asyncCheckIntersect() { + // Set up the empty set of dayBins for intersections, and the array for the news urls + let dayBins = getEmptyLocationBins(); + let name_news = []; + + // get the saved set of locations for the user, normalize and sort + let locationArray = normalizeAndSortLocations(await getSavedLocationArray()); + + // get the health authorities + let authority_list = await GetStoreData(AUTHORITY_SOURCE_SETTINGS); + + if (authority_list) { + // Parse the registered health authorities + authority_list = JSON.parse(authority_list); + + for (const authority of authority_list) { + try { + let responseJson = await retrieveUrlAsJson(authority.url); + + // Update the news array with the info from the authority + name_news.push({ + name: responseJson.authority_name, + news_url: responseJson.info_website, + }); + + // intersect the users location with the locations from the authority + let tempDayBin = intersectSetIntoBins( + locationArray, + normalizeAndSortLocations(responseJson.concern_points), + ); + + // Update each day's bin with the result from the intersection. To keep the + // difference between no data (==-1) and exposure data (>=0), there + // are a total of 3 cases to consider. + dayBins = dayBins.map((currentValue, i) => { + if (currentValue < 0) return tempDayBin[i]; + if (tempDayBin[i] < 0) return currentValue; + return currentValue + tempDayBin[i]; + }); + } catch (error) { + // TODO: We silently fail. Could be a JSON parsing issue, could be a network issue, etc. + // Should do better than this. + console.log('[authority] fetch/parse error :', error); } - }) - .catch(error => console.log('Failed to load authority list', error)); + } + } + + // Store the news arary for the authorities found. + SetStoreData(AUTHORITY_NEWS, name_news); + + // if any of the bins are > 0, tell the user + if (dayBins.some(a => a > 0)) notifyUserOfRisk(); + + // store the results + SetStoreData(CROSSED_PATHS, dayBins); // TODO: Store per authority? + + // save off the current time as the last checked time + let unixtimeUTC = dayjs().valueOf(); + SetStoreData(LAST_CHECKED, unixtimeUTC); + + return dayBins; } -/** Number of day in the standard day-bin array (4 weeks) */ -const DAY_BIN_SIZE = 28; +export function getEmptyLocationBins( + exposureWindowDays = MAX_EXPOSURE_WINDOW_DAYS, +) { + return new Array(exposureWindowDays).fill(-1); +} + +/** + * Notify the user that they are possibly at risk + */ +function notifyUserOfRisk() { + PushNotification.localNotification({ + title: languages.t('label.push_at_risk_title'), + message: languages.t('label.push_at_risk_message'), + }); +} + +/** + * Return Json retrieved from a URL + * + * @param {*} url + */ +async function retrieveUrlAsJson(url) { + let response = await fetch(url); + let responseJson = await response.json(); + return responseJson; +} + +/** + * Gets the currently saved locations as a location array + */ +async function getSavedLocationArray() { + let locationArrayString = await GetStoreData(LOCATION_DATA); + if (locationArrayString !== null) { + return JSON.parse(locationArrayString); + } + return []; +} /** Set the app into debug mode */ export function enableDebugMode() { @@ -304,11 +376,14 @@ export function enableDebugMode() { // Create faux intersection data let pseudoBin = []; - for (let i = 0; i < DAY_BIN_SIZE; i++) { - const intersections = Math.max(0, Math.floor(Math.random() * 50 - 20)); + for (let i = 0; i < MAX_EXPOSURE_WINDOW_DAYS; i++) { + let intersections = + Math.max(0, Math.floor(Math.random() * 50 - 20)) * 60 * 1000; // in millis + if (intersections == 0 && Math.random() < 0.3) intersections = -1; // about 30% of negative will be set as no data pseudoBin.push(intersections); } let dayBin = JSON.stringify(pseudoBin); + console.log(dayBin); SetStoreData(CROSSED_PATHS, dayBin); } @@ -316,7 +391,7 @@ export function enableDebugMode() { export function disableDebugMode() { // Wipe faux intersection data let pseudoBin = []; - for (let i = 0; i < DAY_BIN_SIZE; i++) { + for (let i = 0; i < MAX_EXPOSURE_WINDOW_DAYS; i++) { pseudoBin.push(0); } let dayBin = JSON.stringify(pseudoBin); diff --git a/app/helpers/__tests__/Intersect.spec.js b/app/helpers/__tests__/Intersect.spec.js index bfb316a4cf..3380d4dbb8 100644 --- a/app/helpers/__tests__/Intersect.spec.js +++ b/app/helpers/__tests__/Intersect.spec.js @@ -1,19 +1,841 @@ -import { isLocationsNearby } from '../Intersect'; +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import MockDate from 'mockdate'; -describe('isLocationsNearby', () => { +import { + CONCERN_TIME_WINDOW_MINUTES, + DEFAULT_EXPOSURE_PERIOD_MINUTES, +} from '../../constants/history'; +import { + areLocationsNearby, + getEmptyLocationBins, + intersectSetIntoBins, + normalizeAndSortLocations, +} from '../Intersect'; - // simple tests for the isLocationNearby function +// Base moment used in all tests. +// This is SUPER hacky ... it will effectively ensure that tests run near noon (unless otherwise offset) +// +// TODO: Do better than this. Find something that properly allows for mocking times and timezones (and actually works) +// +const TEST_MOMENT = dayjs('2020-04-17T14:00:00.000Z') + .startOf('day') + .add(12, 'hours'); - it('north and south poles not nearby',() => { - expect(isLocationsNearby(90,0,-90,0)).toBe(false); +/** + * locations used in testing. Set up as a single object to help simplify test setup. + * Kansas City, MO, USA + * Hammerfest, Norway + * Hobart, Tasmania, Australia + * Munich, Germany + * La Concordia, Costa Rica + */ +const TEST_LOCATIONS = { + kansascity: { + base: { + lat: 39.09772, + lon: -94.582959, + }, + concern: { + lat: 39.097769, + lon: -94.582937, + }, + no_concern: { + lat: 39.1, + lon: -94.6, + }, + }, + hammerfest: { + base: { + lat: 70.663017, + lon: 23.682105, + }, + concern: { + lat: 70.66302, + lon: 23.6822, + }, + no_concern: { + lat: 70.662482, + lon: 23.68115, + }, + }, + hobart: { + base: { + lat: -42.8821, + lon: 147.3272, + }, + concern: { + lat: -42.88215, + lon: 147.32721, + }, + no_concern: { + lat: -42.883, + lon: 147.328, + }, + }, + munich: { + base: { + lat: 48.1351, + lon: 11.582, + }, + concern: { + lat: 48.13515, + lon: 11.58201, + }, + no_concern: { + lat: 48.136, + lon: 11.584, + }, + }, + laconcordia: { + base: { + lat: 0.01, + lon: -79.3918, + }, + concern: { + lat: 0.01005, + lon: -79.39185, + }, + no_concern: { + lat: 0.01015, + lon: -79.3912, + }, + }, +}; + +/** + * Intersect tests with empty location sets. Multiple cases covered. + */ +describe('intersect with empty sets', () => { + beforeEach(() => { + MockDate.set(TEST_MOMENT.valueOf()); + }); + + afterEach(() => { + MockDate.reset(); + }); + + /** + * Simplest case, empty data, should be no results in all bins + */ + it('empty locations vs empty concern locations has no data result', () => { + let baseLocations = []; + let concernLocations = []; + + // normalize and sort + baseLocations = normalizeAndSortLocations(baseLocations); + concernLocations = normalizeAndSortLocations(concernLocations); + + let resultBins = intersectSetIntoBins(baseLocations, concernLocations); + let expectedBins = getEmptyLocationBins(); + + expect(resultBins).toEqual(expectedBins); + }); + + /** + * Empty locations, some concern locations + */ + it('empty locations vs real concern locations has no data result', () => { + let baseLocations = []; + + // a few concern locations, going back a while + let concernLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.kansascity.concern, + TEST_MOMENT.valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hammerfest.concern, + TEST_MOMENT.clone() + .subtract(3, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hobart.concern, + TEST_MOMENT.clone() + .subtract(7, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.munich.concern, + TEST_MOMENT.clone() + .subtract(10, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.laconcordia.concern, + TEST_MOMENT.clone() + .subtract(17, 'days') + .valueOf(), + ), + ]; + + // normalize and sort + baseLocations = normalizeAndSortLocations(baseLocations); + concernLocations = normalizeAndSortLocations(concernLocations); + + let resultBins = intersectSetIntoBins(baseLocations, concernLocations); + let expectedBins = getEmptyLocationBins(); + + expect(resultBins).toEqual(expectedBins); + }); + + /** + * This checks having locaitons, but the health authority is returning + * no locations of concern. Should get "0" in bins with location data + */ + it('locations vs empty concern locations has correct concern result', () => { + // a few base locations, going back a while + let baseLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.kansascity.concern, + TEST_MOMENT.valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hammerfest.concern, + TEST_MOMENT.clone() + .subtract(3, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hobart.concern, + TEST_MOMENT.clone() + .subtract(7, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.munich.concern, + TEST_MOMENT.clone() + .subtract(10, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.laconcordia.concern, + TEST_MOMENT.clone() + .subtract(17, 'days') + .valueOf(), + ), + ]; + + // no concern locations + let concernLocations = []; + + // normalize and sort + baseLocations = normalizeAndSortLocations(baseLocations); + concernLocations = normalizeAndSortLocations(concernLocations); + + let resultBins = intersectSetIntoBins(baseLocations, concernLocations); + let expectedBins = getEmptyLocationBins(); + expectedBins[0] = 0; // expect 0 (not -1) becuase we have location data for this bin + expectedBins[3] = 0; // expect 0 (not -1) becuase we have location data for this bin + expectedBins[7] = 0; // expect 0 (not -1) becuase we have location data for this bin + expectedBins[10] = 0; // expect 0 (not -1) becuase we have location data for this bin + + expect(resultBins).toEqual(expectedBins); + }); +}); + + +/** + * More realistic tests, where there are locations and concern locations + */ +describe('intersect at fixed locations and times', () => { + dayjs.extend(duration); + + beforeEach(() => { + MockDate.set(TEST_MOMENT.valueOf()); + }); + + afterEach(() => { + MockDate.reset(); + }); + + /** + * Exact matches in the baseLocations and the concernLocations + */ + it('exact intersect has known result', () => { + // 5 locations, spread over 17 days. Note, the final location is over the 14 days that intersect covers + let baseLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.kansascity.base, + TEST_MOMENT.valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hammerfest.base, + //TEST_MOMENT_MS - 3 * 24 * 60 * 60 * 1000, + TEST_MOMENT.clone() + .subtract(3, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hobart.base, + TEST_MOMENT.clone() + .subtract(7, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.munich.base, + TEST_MOMENT.clone() + .subtract(10, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.laconcordia.base, + TEST_MOMENT.clone() + .subtract(17, 'days') + .valueOf(), + ), + ]; + // same locations for the concern array, at the same times + let concernLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.kansascity.concern, + TEST_MOMENT.valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hammerfest.concern, + TEST_MOMENT.clone() + .subtract(3, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hobart.concern, + TEST_MOMENT.clone() + .subtract(7, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.munich.concern, + TEST_MOMENT.clone() + .subtract(10, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.laconcordia.concern, + TEST_MOMENT.clone() + .subtract(17, 'days') + .valueOf(), + ), + ]; + + // normalize and sort + baseLocations = normalizeAndSortLocations(baseLocations); + concernLocations = normalizeAndSortLocations(concernLocations); + + let resultBins = intersectSetIntoBins(baseLocations, concernLocations); + + let expectedBins = getEmptyLocationBins(); + expectedBins[0] = dayjs + .duration(60 + DEFAULT_EXPOSURE_PERIOD_MINUTES, 'minutes') + .asMilliseconds(); // expect 60 minutes + 5 minutes for the final data point that takes the default + expectedBins[3] = dayjs + .duration(60 + DEFAULT_EXPOSURE_PERIOD_MINUTES, 'minutes') + .asMilliseconds(); // expect 60 minutes + 5 minutes for the final data point that takes the default + expectedBins[7] = dayjs + .duration(60 + DEFAULT_EXPOSURE_PERIOD_MINUTES, 'minutes') + .asMilliseconds(); // expect 60 minutes + 5 minutes for the final data point that takes the default + expectedBins[10] = dayjs + .duration(60 + DEFAULT_EXPOSURE_PERIOD_MINUTES, 'minutes') + .asMilliseconds(); // expect 60 minutes + 5 minutes for the final data point that takes the default + + expect(resultBins).toEqual(expectedBins); + }); + + /** + * same locations in base and concern sets, but at different times (so no concern) + */ + it('differing times (nothing in common) shows no concern', () => { + // 5 locations, spread over 17 days. Note, the final location is over the 14 days that intersect covers + let baseLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.kansascity.base, + TEST_MOMENT.valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hammerfest.base, + TEST_MOMENT.clone() + .subtract(3, 'days') + .valueOf(), // - dayjs.duration(3, 'days').asMilliseconds(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hobart.base, + TEST_MOMENT.clone() + .subtract(7, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.munich.base, + TEST_MOMENT.clone() + .subtract(10, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.laconcordia.base, + TEST_MOMENT.clone() + .subtract(17, 'days') + .valueOf(), + ), + ]; + // LOOK SHARP ... the locations are in a different order (so at different times) + let concernLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.hammerfest.concern, + TEST_MOMENT.valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.laconcordia.concern, + TEST_MOMENT.clone() + .subtract(3, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.kansascity.concern, + TEST_MOMENT.clone() + .subtract(7, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hobart.concern, + TEST_MOMENT.clone() + .subtract(10, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.munich.concern, + TEST_MOMENT.clone() + .subtract(17, 'days') + .valueOf(), + ), + ]; + + // normalize and sort + baseLocations = normalizeAndSortLocations(baseLocations); + concernLocations = normalizeAndSortLocations(concernLocations); + + let resultBins = intersectSetIntoBins(baseLocations, concernLocations); + let expectedBins = getEmptyLocationBins(); // expect no concern time in any of the bins + expectedBins[0] = 0; // expect 0 (not -1) becuase we have location data for this bin + expectedBins[3] = 0; // expect 0 (not -1) becuase we have location data for this bin + expectedBins[7] = 0; // expect 0 (not -1) becuase we have location data for this bin + expectedBins[10] = 0; // expect 0 (not -1) becuase we have location data for this bin + + expect(resultBins).toEqual(expectedBins); + }); + + /** + * same general locations and times, but distance apart in each is just over the threshold, so no concern + */ + it('distances slightly over the threshold shows no concern', () => { + // 5 locations, spread over 17 days. Note, the final location is over the 14 days that intersect covers + let baseLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.kansascity.base, + TEST_MOMENT.valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hammerfest.base, + TEST_MOMENT.clone() + .subtract(3, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hobart.base, + TEST_MOMENT.clone() + .subtract(7, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.munich.base, + TEST_MOMENT.clone() + .subtract(10, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.laconcordia.base, + TEST_MOMENT.clone() + .subtract(17, 'days') + .valueOf(), + ), + ]; + + // same locations for the concern array + let concernLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.hammerfest.no_concern, + TEST_MOMENT.valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.laconcordia.no_concern, + TEST_MOMENT.clone() + .subtract(3, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.kansascity.no_concern, + TEST_MOMENT.clone() + .subtract(7, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hobart.no_concern, + TEST_MOMENT.clone() + .subtract(10, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.munich.no_concern, + TEST_MOMENT.clone() + .subtract(17, 'days') + .valueOf(), + ), + ]; + + // normalize and sort + baseLocations = normalizeAndSortLocations(baseLocations); + concernLocations = normalizeAndSortLocations(concernLocations); + + let resultBins = intersectSetIntoBins(baseLocations, concernLocations); + let expectedBins = getEmptyLocationBins(); // expect no concern time in any of the bins + expectedBins[0] = 0; // expect 0 (not -1) becuase we have location data for this bin + expectedBins[3] = 0; // expect 0 (not -1) becuase we have location data for this bin + expectedBins[7] = 0; // expect 0 (not -1) becuase we have location data for this bin + expectedBins[10] = 0; // expect 0 (not -1) becuase we have location data for this bin + + expect(resultBins).toEqual(expectedBins); + }); + + /** + * specific test with two locations, with times offset to cross beyond the max offset window, + * so should be only partial overlaps. + */ + it('offset in time shows correct partial overlaps ', () => { + // 2 locations + let baseLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.laconcordia.base, + TEST_MOMENT.valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.munich.base, + TEST_MOMENT.clone() + .subtract(3, 'days') + .valueOf(), + ), + ]; + + // same locations for the concern array, the first is offset back 30 minutes over the offset window, the second + // is offset 30 minutes forward + let concernLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.laconcordia.concern, + TEST_MOMENT.clone() + .subtract(CONCERN_TIME_WINDOW_MINUTES + 30, 'minutes') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.munich.concern, + TEST_MOMENT.clone() + .subtract(3, 'days') + .add(30, 'minutes') + .valueOf(), + ), + ]; + + // normalize and sort + baseLocations = normalizeAndSortLocations(baseLocations); + concernLocations = normalizeAndSortLocations(concernLocations); + + let resultBins = intersectSetIntoBins(baseLocations, concernLocations); + let expectedBins = getEmptyLocationBins(); + + expectedBins[0] = dayjs + .duration(30 + DEFAULT_EXPOSURE_PERIOD_MINUTES, 'minutes') + .asMilliseconds(); // 2100000 - expect 30 minutes + 5 minutes for the final data point that takes the default + expectedBins[3] = dayjs + .duration(30 + DEFAULT_EXPOSURE_PERIOD_MINUTES, 'minutes') + .asMilliseconds(); // expect 30 minutes + 5 minutes for the final data point that takes the default + + expect(resultBins).toEqual(expectedBins); + }); + + /** + * specific test with two locations, and the concern data has multiple matches in the timeframes + * of concern. This verifies we're not double counting exposures in this case + */ + it('is not double counting exposure times with multiple results for a location', () => { + // 5 locations, spread over 17 days. Note, the final location is over the 14 days that intersect covers + let baseLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.laconcordia.base, + TEST_MOMENT.valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.munich.base, + TEST_MOMENT.clone() + .subtract(3, 'days') + .valueOf(), + ), + ]; + + // locations with a fair amount of expected overlap + let concernLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.laconcordia.concern, + TEST_MOMENT.valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.laconcordia.concern, + TEST_MOMENT.clone() + .subtract(3, 'minutes') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.munich.concern, + TEST_MOMENT.clone() + .subtract(3, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.munich.concern, + TEST_MOMENT.clone() + .subtract(3, 'days') + .subtract(12, 'minutes') + .valueOf(), + ), + ]; + + // normalize and sort + baseLocations = normalizeAndSortLocations(baseLocations); + concernLocations = normalizeAndSortLocations(concernLocations); + + let resultBins = intersectSetIntoBins(baseLocations, concernLocations); + let expectedBins = getEmptyLocationBins(); + + expectedBins[0] = dayjs + .duration(60 + DEFAULT_EXPOSURE_PERIOD_MINUTES, 'minutes') + .asMilliseconds(); // 3900000 expect 60 minutes + 5 minutes for the final data point that takes the default + expectedBins[3] = dayjs + .duration(60 + DEFAULT_EXPOSURE_PERIOD_MINUTES, 'minutes') + .asMilliseconds(); // 3900000 expect 60 minutes + 5 minutes for the final data point that takes the default + + expect(resultBins).toEqual(expectedBins); + }); + + /** + * specific test with two locations, with altered defaults. more dayBins, shorter backfill times + */ + it('is counting non-standard intervals correctly', () => { + // 5 locations, spread over 17 days. Note, the final location is over the 14 days that intersect covers + let baseLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.laconcordia.base, + TEST_MOMENT.valueOf(), + dayjs.duration(1, 'hour').asMilliseconds(), // 1000 * 60 * 60 still only 1 hour + dayjs.duration(4, 'minutes').asMilliseconds(), // 1000 * 60 * 4 backfill interval is 4 minutes + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.munich.base, + TEST_MOMENT.clone() + .subtract(3, 'days') + .valueOf(), + dayjs.duration(1, 'hour').asMilliseconds(), // 1000 * 60 * 60 still only 1 hour + dayjs.duration(15, 'minutes').asMilliseconds(), //1000 * 60 * 15 backfill interval is 15 minutes, or a total of 5 location points + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.kansascity.base, + TEST_MOMENT.valueOf() - dayjs.duration(17, 'days').asMilliseconds(), // 17 * 24 * 60 * 60 * 1000, + ), + ]; + + // same locations for the concern array, the first is offset back 30 minutes over the offset window, the second + // is offset 30 minutes forward + let concernLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.laconcordia.concern, + TEST_MOMENT.valueOf(), + dayjs.duration(1, 'hour').asMilliseconds(), // 1000 * 60 * 60 still only 1 hour + dayjs.duration(1, 'minutes').asMilliseconds(), //1000 * 60 * 1 backfill interval is 1 minute + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.munich.concern, + TEST_MOMENT.clone() + .subtract(3, 'days') + .valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.kansascity.concern, + TEST_MOMENT.clone() + .subtract(17, 'days') + .valueOf(), + ), + ]; + + // normalize and sort + baseLocations = normalizeAndSortLocations(baseLocations); + concernLocations = normalizeAndSortLocations(concernLocations); + + let resultBins = intersectSetIntoBins( + baseLocations, + concernLocations, + 21, // override to 21 dayBins + dayjs.duration(CONCERN_TIME_WINDOW_MINUTES, 'minutes').asMilliseconds(), // setting the concern time window + dayjs + .duration(DEFAULT_EXPOSURE_PERIOD_MINUTES + 1, 'minutes') + .asMilliseconds(), //override the exposure period to 1 minute longer that the default + ); + let expectedBins = getEmptyLocationBins(21); + + expectedBins[0] = dayjs + .duration(60 + DEFAULT_EXPOSURE_PERIOD_MINUTES + 1, 'minutes') + .asMilliseconds(); // 3960000 - Should end up counting 66 minutes total at loconcoria (60 minutes, plus the one at the current moment @ the 6 minute default) + expectedBins[3] = dayjs + .duration(6 * DEFAULT_EXPOSURE_PERIOD_MINUTES, 'minutes') + .asMilliseconds(); // 5 * 6 * 60 * 1000; // Should end up counting 30 minutes exposure for munich (5 exposures, 6 minutes each) + expectedBins[17] = dayjs + .duration(60 + DEFAULT_EXPOSURE_PERIOD_MINUTES + 1, 'minutes') + .asMilliseconds(); // 3960000 - Should end up counting 66 minutes total at kansascity (60 minutes, plus the one at the current moment @ the 6 minute default) + + expect(resultBins).toEqual(expectedBins); + }); +}); + +/** + * These are tests running at interesting times, such as near midnight or at a + * daylight savings change. + * + * TODO: daylight savings not yet tested + */ +describe('instersect at interesting times', () => { + dayjs.extend(duration); + + afterEach(() => { + MockDate.reset(); + }); + + /** + * Overlaps crossing midnight - should add into two different bins + */ + it('is counting intersections spanning midnight into the proper bins', () => { + // + // TODO: Do better than this way of setting the time + // + // Another SUPER hacky solution. This ensures that the the datetime evaluation + // will for sure appear to be 12:31am when the code executes + // + const THIS_TEST_MOMENT = TEST_MOMENT.startOf('day').add(31, 'minutes'); + MockDate.set(THIS_TEST_MOMENT.valueOf()); + + // 2 locations ... the time period will span midnight + let baseLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.kansascity.base, + THIS_TEST_MOMENT.valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hammerfest.base, + //TEST_MOMENT_MS - 3 * 24 * 60 * 60 * 1000, + THIS_TEST_MOMENT.clone() + .subtract(3, 'days') + .valueOf(), + ), + ]; + // same locations, still spanning midnight + let concernLocations = [ + ...generateBackfillLocationArray( + TEST_LOCATIONS.kansascity.concern, + THIS_TEST_MOMENT.valueOf(), + ), + ...generateBackfillLocationArray( + TEST_LOCATIONS.hammerfest.concern, + THIS_TEST_MOMENT.clone() + .subtract(3, 'days') + .valueOf(), + ), + ]; + + // normalize and sort + baseLocations = normalizeAndSortLocations(baseLocations); + concernLocations = normalizeAndSortLocations(concernLocations); + + let resultBins = intersectSetIntoBins(baseLocations, concernLocations); + + let expectedBins = getEmptyLocationBins(); + expectedBins[0] = dayjs + .duration(30 + DEFAULT_EXPOSURE_PERIOD_MINUTES, 'minutes') + .asMilliseconds(); // expect 30 minutes + 5 minutes for "today" + expectedBins[1] = dayjs.duration(30, 'minutes').asMilliseconds(); // expect 30 minutes for "yesterday" + expectedBins[3] = dayjs + .duration(30 + DEFAULT_EXPOSURE_PERIOD_MINUTES, 'minutes') + .asMilliseconds(); // expect 30 minutes + 5 minutes (same as before) + expectedBins[4] = dayjs.duration(30, 'minutes').asMilliseconds(); // expect 30 minutes (same as before) + + expect(resultBins).toEqual(expectedBins); + }); + + /** + * A daylight savings time change should cause either a 23 or 25 hour day. + * + * TODO: Once we've figured out a good way to mock the timezone and DST, make this test work + */ + //it('is not affectd by daylight saving changes', () => { + // + // TODO: Implement this test. Note that until we figure out a good way to mock both the + // timezone as well as DST, this test can't be done in a meaningful way. + // + //}); +}); + +/** + * Simple tests for the areLocationsNearby function + */ +describe('areLocationsNearby', () => { + /** + * test that north and south poles are far apart! + */ + it('north and south poles not nearby', () => { + expect(areLocationsNearby(90, 0, -90, 0)).toBe(false); }); - it('New York and Sydney are not nearby',() => { - expect(isLocationsNearby(40.7128,-74.0060,-33.8688,151.2093)).toBe(false); + /** + * New York and Sydney are far apart too (crossing both the equator and date line) + */ + it('New York and Sydney are not nearby', () => { + expect(areLocationsNearby(40.7128, -74.006, -33.8688, 151.2093)).toBe( + false, + ); }); - it('two spots in Kansas City are nearby',() => { - expect(isLocationsNearby(39.097720,-94.582959,39.097769,-94.582937)).toBe(true); + /** + * Two spots in downtown KC that are with about 15 feet of one another + */ + it('two spots in Kansas City are nearby', () => { + expect( + areLocationsNearby(39.09772, -94.582959, 39.097769, -94.582937), + ).toBe(true); }); +}); -}) +/** + * Helper for building up the location arrays + * + * @param {*} location + * @param {*} startTimeMS + * @param {*} backfillTimeMS + * @param {*} backfillIntervalMS + */ +function generateBackfillLocationArray( + location, + startTimeMS, + backfillTimeMS = 1000 * 60 * 60, + backfillIntervalMS = 1000 * 60 * 5, +) { + let ar = []; + for ( + let t = startTimeMS; + t >= startTimeMS - backfillTimeMS; + t -= backfillIntervalMS + ) { + ar.push({ time: t, latitude: location.lat, longitude: location.lon }); + } + return ar; +} diff --git a/app/views/ExposureHistory/ExposureHistory.js b/app/views/ExposureHistory/ExposureHistory.js index ed1fa44e29..8d9ed2f8fc 100644 --- a/app/views/ExposureHistory/ExposureHistory.js +++ b/app/views/ExposureHistory/ExposureHistory.js @@ -1,11 +1,12 @@ import { css } from '@emotion/native'; import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; import React, { useEffect, useState } from 'react'; import { BackHandler, ScrollView } from 'react-native'; import NavigationBarWrapper from '../../components/NavigationBarWrapper'; import { Typography } from '../../components/Typography'; -import { BIN_DURATION, MAX_EXPOSURE_WINDOW } from '../../constants/history'; +import { MAX_EXPOSURE_WINDOW } from '../../constants/history'; import { CROSSED_PATHS } from '../../constants/storage'; import { Theme, charcoal, defaultTheme } from '../../constants/themes'; import { GetStoreData } from '../../helpers/General'; @@ -96,6 +97,7 @@ export const ExposureHistoryScreen = ({ navigation }) => { * @returns {import('../../constants/history').History} Array of exposed minutes per day starting at today */ export function convertToDailyMinutesExposed(dayBin) { + dayjs.extend(duration); let dayBinParsed = JSON.parse(dayBin); if (!dayBinParsed) { @@ -107,10 +109,10 @@ export function convertToDailyMinutesExposed(dayBin) { const dailyMinutesExposed = dayBinParsed .slice(0, MAX_EXPOSURE_WINDOW) // last two weeks of crossing data only - .map((binCount, i) => { + .map((binExposureTime, i) => { return { date: today.startOf('day').subtract(i, 'day'), - exposureMinutes: binCount * BIN_DURATION, + exposureMinutes: dayjs.duration(binExposureTime).asMinutes(), }; }); return dailyMinutesExposed; diff --git a/app/views/ExposureHistory/__tests__/ExposureHistory.spec.js b/app/views/ExposureHistory/__tests__/ExposureHistory.spec.js index f04399d5e2..caac89c889 100644 --- a/app/views/ExposureHistory/__tests__/ExposureHistory.spec.js +++ b/app/views/ExposureHistory/__tests__/ExposureHistory.spec.js @@ -23,7 +23,9 @@ describe('convertToDailyMinutesExposed', () => { }); it('converts day bins to minutes', () => { - expect(convertToDailyMinutesExposed('[0, 1, 2, 3, 0]')).toEqual([ + expect( + convertToDailyMinutesExposed('[0, 300000, 600000, 900000, 0]'), + ).toEqual([ { date: expect.any(dayjs), exposureMinutes: 0 }, { date: expect.any(dayjs), exposureMinutes: 5 }, { date: expect.any(dayjs), exposureMinutes: 10 },