{t('survey.powered-by')}{' '}
diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx
index 249d7f366..c4d28eee8 100644
--- a/www/js/survey/enketo/UserInputButton.tsx
+++ b/www/js/survey/enketo/UserInputButton.tsx
@@ -14,28 +14,46 @@ import { useTranslation } from 'react-i18next';
import { useTheme } from 'react-native-paper';
import { displayErrorMsg, logDebug } from '../../plugin/logger';
import EnketoModal from './EnketoModal';
-import LabelTabContext from '../../diary/LabelTabContext';
+import TimelineContext from '../../TimelineContext';
+import useAppConfig from '../../useAppConfig';
+import { getSurveyForTimelineEntry } from './conditionalSurveys';
+import useDerivedProperties from '../../diary/useDerivedProperties';
+import { resolveSurveyButtonConfig } from './enketoHelper';
+import { SurveyButtonConfig } from '../../types/appConfigTypes';
type Props = {
timelineEntry: any;
};
const UserInputButton = ({ timelineEntry }: Props) => {
const { colors } = useTheme();
+ const appConfig = useAppConfig();
const { t, i18n } = useTranslation();
const [prevSurveyResponse, setPrevSurveyResponse] = useState
(undefined);
const [modalVisible, setModalVisible] = useState(false);
- const { userInputFor, addUserInputToEntry } = useContext(LabelTabContext);
+ const { userInputFor, addUserInputToEntry } = useContext(TimelineContext);
+ const derivedTripProps = useDerivedProperties(timelineEntry);
- // the label resolved from the survey response, or null if there is no response yet
- const responseLabel = useMemo(
- () => userInputFor(timelineEntry)?.['SURVEY']?.data.label || undefined,
- [userInputFor(timelineEntry)?.['SURVEY']?.data.label],
- );
+ // which survey will this button launch?
+ const survey = useMemo(() => {
+ if (!appConfig) return null; // no config loaded yet; show blank for now
+ const possibleSurveysForButton = resolveSurveyButtonConfig(appConfig, 'trip-label');
+ // if there is only one survey, no need to check further
+ if (possibleSurveysForButton.length == 1) return possibleSurveysForButton[0];
+ // config lists one or more surveys; find which one to use for this timeline entry
+ return getSurveyForTimelineEntry(possibleSurveysForButton, timelineEntry, derivedTripProps);
+ }, [appConfig, timelineEntry, i18n.resolvedLanguage]);
+
+ // the label resolved from the survey response, or undefined if there is no response yet
+ const responseLabel = useMemo(() => {
+ if (!survey) return undefined;
+ return userInputFor(timelineEntry)?.[survey.surveyName]?.data.label || undefined;
+ }, [survey, userInputFor(timelineEntry)?.[survey?.surveyName || '']?.data.label]);
function launchUserInputSurvey() {
+ if (!survey) return displayErrorMsg('UserInputButton: no survey to launch');
logDebug('UserInputButton: About to launch survey');
- const prevResponse = userInputFor(timelineEntry)?.['SURVEY'];
+ const prevResponse = userInputFor(timelineEntry)?.[survey.surveyName];
if (prevResponse?.data?.xmlResponse) {
setPrevSurveyResponse(prevResponse.data.xmlResponse);
}
@@ -46,29 +64,27 @@ const UserInputButton = ({ timelineEntry }: Props) => {
if (result) {
logDebug(`UserInputButton: response was saved, about to addUserInputToEntry;
result = ${JSON.stringify(result)}`);
- addUserInputToEntry(timelineEntry._id.$oid, { SURVEY: result }, 'label');
+ addUserInputToEntry(timelineEntry._id.$oid, { [result.name]: result }, 'label');
} else {
displayErrorMsg('UserInputButton: response was not saved, result=', result);
}
}
+ if (!survey) return <>>; // no survey to launch
return (
<>
launchUserInputSurvey()}>
- {/* if no response yet, show the default label */}
- {responseLabel || t('diary.choose-survey')}
+ {responseLabel || survey['not-filled-in-label'][i18n.resolvedLanguage || 'en']}
setModalVisible(false)}
onResponseSaved={onResponseSaved}
- surveyName={'TripConfirmSurvey'} /* As of now, the survey name is hardcoded.
- In the future, if we ever implement something like
- a "Place Details" survey, we may want to make this
- configurable. */
+ surveyName={survey.surveyName}
opts={{ timelineEntry, prefilledSurveyResponse: prevSurveyResponse }}
/>
>
diff --git a/www/js/survey/enketo/conditionalSurveys.ts b/www/js/survey/enketo/conditionalSurveys.ts
new file mode 100644
index 000000000..a96ee2de8
--- /dev/null
+++ b/www/js/survey/enketo/conditionalSurveys.ts
@@ -0,0 +1,52 @@
+import { displayError } from '../../plugin/logger';
+import { SurveyButtonConfig } from '../../types/appConfigTypes';
+import { DerivedProperties, TimelineEntry } from '../../types/diaryTypes';
+import { Position } from 'geojson';
+
+const conditionalSurveyFunctions = {
+ /**
+ @description Returns true if the given point is within the given bounds.
+ Coordinates are in [longitude, latitude] order, since that is the GeoJSON spec.
+ @param pt point to check as [lon, lat]
+ @param bounds NW and SE corners as [[lon, lat], [lon, lat]]
+ @returns true if pt is within bounds
+ */
+ pointIsWithinBounds: (pt: Position, bounds: Position[]) => {
+ // pt's lon must be east of, or greater than, NW's lon; and west of, or less than, SE's lon
+ const lonInRange = pt[0] > bounds[0][0] && pt[0] < bounds[1][0];
+ // pt's lat must be south of, or less than, NW's lat; and north of, or greater than, SE's lat
+ const latInRange = pt[1] < bounds[0][1] && pt[1] > bounds[1][1];
+ return latInRange && lonInRange;
+ },
+};
+
+/**
+ * @description Executes a JS expression `script` in a restricted `scope`
+ * @example scopedEval('console.log(foo)', { foo: 'bar' })
+ */
+const scopedEval = (script: string, scope: { [k: string]: any }) =>
+ Function(...Object.keys(scope), `return ${script}`)(...Object.values(scope));
+
+// the first survey in the list that passes its condition will be returned
+export function getSurveyForTimelineEntry(
+ possibleSurveys: SurveyButtonConfig[],
+ tlEntry: TimelineEntry,
+ derivedProperties: DerivedProperties,
+) {
+ for (let survey of possibleSurveys) {
+ if (!survey.showsIf) return survey; // survey shows unconditionally
+ const scope = {
+ ...tlEntry,
+ ...derivedProperties,
+ ...conditionalSurveyFunctions,
+ };
+ try {
+ const evalResult = scopedEval(survey.showsIf, scope);
+ if (evalResult) return survey;
+ } catch (e) {
+ displayError(e, `Error evaluating survey condition "${survey.showsIf}"`);
+ }
+ }
+ // TODO if none of the surveys passed conditions?? should we return null, throw error, or return a default?
+ return null;
+}
diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts
index 2113c5deb..6ebe5f630 100644
--- a/www/js/survey/enketo/enketoHelper.ts
+++ b/www/js/survey/enketo/enketoHelper.ts
@@ -8,7 +8,7 @@ import { getConfig } from '../../config/dynamicConfig';
import { DateTime } from 'luxon';
import { fetchUrlCached } from '../../services/commHelper';
import { getUnifiedDataForInterval } from '../../services/unifiedDataLoader';
-import { AppConfig, EnketoSurveyConfig } from '../../types/appConfigTypes';
+import { AppConfig, EnketoSurveyConfig, SurveyButtonConfig } from '../../types/appConfigTypes';
import {
CompositeTrip,
ConfirmedPlace,
@@ -315,6 +315,32 @@ export function loadPreviousResponseForSurvey(dataKey: string) {
);
}
+/**
+ * @description Returns an array of surveys that could be prompted for one button in the UI (trip label, trip notes, place label, or place notes)
+ * (If multiple are returned, they will show conditionally in the UI based on their `showsIf` field)
+ * Includes backwards compats for app config fields that didn't use to exist
+ */
+export function resolveSurveyButtonConfig(
+ config: AppConfig,
+ button: 'trip-label' | 'trip-notes' | 'place-label' | 'place-notes',
+): SurveyButtonConfig[] {
+ const buttonConfig = config.survey_info.buttons?.[button];
+ // backwards compat: default to the trip confirm survey if this button isn't configured
+ if (!buttonConfig) {
+ return [
+ {
+ surveyName: 'TripConfirmSurvey',
+ 'not-filled-in-label': {
+ en: 'Add Trip Details',
+ es: 'Agregar detalles del viaje',
+ lo: 'ເພີ່ມລາຍລະອຽດການເດີນທາງ',
+ },
+ },
+ ];
+ }
+ return buttonConfig instanceof Array ? buttonConfig : [buttonConfig];
+}
+
export async function fetchSurvey(url: string) {
logDebug('fetchSurvey: url = ' + url);
const responseText = await fetchUrlCached(url);
diff --git a/www/js/survey/enketo/infinite_scroll_filters.ts b/www/js/survey/enketo/infinite_scroll_filters.ts
index d4b281713..512b272c4 100644
--- a/www/js/survey/enketo/infinite_scroll_filters.ts
+++ b/www/js/survey/enketo/infinite_scroll_filters.ts
@@ -8,7 +8,8 @@
import i18next from 'i18next';
-const unlabeledCheck = (trip, userInputForTrip) => !userInputForTrip?.['SURVEY'];
+const unlabeledCheck = (trip, userInputForTrip) =>
+ !userInputForTrip || !Object.values(userInputForTrip).some((input) => input);
const TO_LABEL = {
key: 'to_label',
diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts
index da802d2e8..604c533b2 100644
--- a/www/js/survey/inputMatcher.ts
+++ b/www/js/survey/inputMatcher.ts
@@ -1,16 +1,28 @@
import { logDebug, displayErrorMsg } from '../plugin/logger';
import { DateTime } from 'luxon';
-import { CompositeTrip, ConfirmedPlace, TimelineEntry, UserInputEntry } from '../types/diaryTypes';
-import { keysForLabelInputs, unprocessedLabels, unprocessedNotes } from '../diary/timelineHelper';
+import {
+ BluetoothBleData,
+ CompositeTrip,
+ ConfirmedPlace,
+ TimelineEntry,
+ UserInputEntry,
+} from '../types/diaryTypes';
+import {
+ keysForLabelInputs,
+ unprocessedBleScans,
+ unprocessedLabels,
+ unprocessedNotes,
+} from '../diary/timelineHelper';
import {
getLabelInputDetails,
inputType2retKey,
removeManualPrefix,
} from './multilabel/confirmHelper';
-import { TimelineLabelMap, TimelineNotesMap } from '../diary/LabelTabContext';
+import { TimelineLabelMap, TimelineNotesMap, UserInputMap } from '../TimelineContext';
import { MultilabelKey } from '../types/labelTypes';
import { EnketoUserInputEntry } from './enketo/enketoHelper';
import { AppConfig } from '../types/appConfigTypes';
+import { BEMData } from '../types/serverData';
const EPOCH_MAXIMUM = 2 ** 31 - 1;
@@ -204,9 +216,8 @@ export function getAdditionsForTimelineEntry(
return [];
}
- // get additions that have not been deleted and filter out additions that do not start within the bounds of the timeline entry
- const notDeleted = getNotDeletedCandidates(additionsList);
- const matchingAdditions = notDeleted.filter((ui) =>
+ // filter out additions that do not start within the bounds of the timeline entry
+ const matchingAdditions = additionsList.filter((ui) =>
validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled),
);
@@ -268,16 +279,16 @@ export function mapInputsToTimelineEntries(
allEntries.forEach((tlEntry, i) => {
const nextEntry = i + 1 < allEntries.length ? allEntries[i + 1] : null;
if (appConfig?.survey_info?.['trip-labels'] == 'ENKETO') {
- // ENKETO configuration: just look for the 'SURVEY' key in the unprocessedInputs
+ // ENKETO configuration: consider reponses from all surveys in unprocessedLabels
const userInputForTrip = getUserInputForTimelineEntry(
tlEntry,
nextEntry,
- unprocessedLabels['SURVEY'],
+ Object.values(unprocessedLabels).flat(1),
) as EnketoUserInputEntry;
if (userInputForTrip) {
- timelineLabelMap[tlEntry._id.$oid] = { SURVEY: userInputForTrip };
+ timelineLabelMap[tlEntry._id.$oid] = { [userInputForTrip.data.name]: userInputForTrip };
} else {
- let processedSurveyResponse;
+ let processedSurveyResponse: EnketoUserInputEntry | undefined;
for (const dataKey of keysForLabelInputs(appConfig)) {
const key = removeManualPrefix(dataKey);
if (tlEntry.user_input?.[key]) {
@@ -285,12 +296,16 @@ export function mapInputsToTimelineEntries(
break;
}
}
- timelineLabelMap[tlEntry._id.$oid] = { SURVEY: processedSurveyResponse };
+ if (processedSurveyResponse) {
+ timelineLabelMap[tlEntry._id.$oid] = {
+ [processedSurveyResponse.data.name]: processedSurveyResponse,
+ };
+ }
}
} else {
// MULTILABEL configuration: use the label inputs from the labelOptions to determine which
// keys to look for in the unprocessedInputs
- const labelsForTrip: { [k: string]: UserInputEntry | undefined } = {};
+ const labelsForTrip: UserInputMap = {};
Object.keys(getLabelInputDetails(appConfig)).forEach((label: MultilabelKey) => {
// Check unprocessed labels first since they are more recent
const userInputForTrip = getUserInputForTimelineEntry(
@@ -340,3 +355,85 @@ export function mapInputsToTimelineEntries(
return [timelineLabelMap, timelineNotesMap];
}
+
+function validBleScanForTimelineEntry(tlEntry: TimelineEntry, bleScan: BEMData) {
+ let entryStart = (tlEntry as CompositeTrip).start_ts || (tlEntry as ConfirmedPlace).enter_ts;
+ let entryEnd = (tlEntry as CompositeTrip).end_ts || (tlEntry as ConfirmedPlace).exit_ts;
+
+ if (!entryStart && entryEnd) {
+ /* if a place has no enter time, this is the first start_place of the first composite trip object
+ so we will set the start time to the start of the day of the end time for the purpose of comparison */
+ entryStart = DateTime.fromSeconds(entryEnd).startOf('day').toUnixInteger();
+ }
+
+ if (!entryEnd) {
+ /* if a place has no exit time, the user hasn't left there yet
+ so we will set the end time as high as possible for the purpose of comparison */
+ entryEnd = EPOCH_MAXIMUM;
+ }
+
+ return bleScan.data.ts >= entryStart && bleScan.data.ts <= entryEnd;
+}
+
+/**
+ * @description Get BLE scans that are of type RANGE_UPDATE and are within the time range of the timeline entry
+ */
+function getBleRangingScansForTimelineEntry(
+ tlEntry: TimelineEntry,
+ bleScans: BEMData[],
+) {
+ return bleScans.filter(
+ (scan) =>
+ /* RANGE_UPDATE is the string value, but the server uses an enum, so once processed it becomes 2 */
+ (scan.data.eventType == 'RANGE_UPDATE' || scan.data.eventType == 2) &&
+ validBleScanForTimelineEntry(tlEntry, scan),
+ );
+}
+
+/**
+ * @description Convert a decimal number to a hexadecimal string, with optional padding
+ * @example decimalToHex(245) => 'f5'
+ * @example decimalToHex(245, 4) => '00f5'
+ */
+function decimalToHex(d: string | number, padding?: number) {
+ let hex = Number(d).toString(16);
+ while (hex.length < (padding || 0)) {
+ hex = '0' + hex;
+ }
+ return hex;
+}
+
+export function mapBleScansToTimelineEntries(allEntries: TimelineEntry[], appConfig: AppConfig) {
+ const timelineBleMap = {};
+ for (const tlEntry of allEntries) {
+ const rangingScans = getBleRangingScansForTimelineEntry(tlEntry, unprocessedBleScans);
+ if (!rangingScans.length) {
+ continue;
+ }
+
+ // count the number of occurrences of each major:minor pair
+ const majorMinorCounts = {};
+ rangingScans.forEach((scan) => {
+ const major = decimalToHex(scan.data.major, 4);
+ const minor = decimalToHex(scan.data.minor, 4);
+ const majorMinor = major + ':' + minor;
+ majorMinorCounts[majorMinor] = majorMinorCounts[majorMinor]
+ ? majorMinorCounts[majorMinor] + 1
+ : 1;
+ });
+ // determine the major:minor pair with the highest count
+ const match = Object.keys(majorMinorCounts).reduce((a, b) =>
+ majorMinorCounts[a] > majorMinorCounts[b] ? a : b,
+ );
+ // find the vehicle identity that uses this major:minor pair
+ const vehicleIdentity = appConfig.vehicle_identities?.find((vi) =>
+ vi.bluetooth_major_minor.includes(match),
+ );
+ if (vehicleIdentity) {
+ timelineBleMap[tlEntry._id.$oid] = vehicleIdentity;
+ } else {
+ displayErrorMsg(`No vehicle identity found for major:minor pair ${match}`);
+ }
+ }
+ return timelineBleMap;
+}
diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx
index 517223141..466bb9868 100644
--- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx
+++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx
@@ -12,10 +12,11 @@ import {
RadioButton,
Button,
TextInput,
+ Divider,
} from 'react-native-paper';
import DiaryButton from '../../components/DiaryButton';
import { useTranslation } from 'react-i18next';
-import LabelTabContext, { UserInputMap } from '../../diary/LabelTabContext';
+import TimelineContext, { UserInputMap } from '../../TimelineContext';
import { displayErrorMsg, logDebug } from '../../plugin/logger';
import {
getLabelInputDetails,
@@ -29,22 +30,23 @@ import {
} from './confirmHelper';
import useAppConfig from '../../useAppConfig';
import { MultilabelKey } from '../../types/labelTypes';
+import { updateUserCustomLabel } from '../../services/commHelper';
+import { AppContext } from '../../App';
const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => {
const { colors } = useTheme();
const { t } = useTranslation();
const appConfig = useAppConfig();
- const { labelOptions, labelFor, userInputFor, addUserInputToEntry } = useContext(LabelTabContext);
+ const { labelOptions, labelFor, userInputFor, addUserInputToEntry } = useContext(TimelineContext);
+ const { customLabelMap, setCustomLabelMap } = useContext(AppContext);
const { height: windowHeight } = useWindowDimensions();
-
// modal visible for which input type? (MODE or PURPOSE or REPLACED_MODE, null if not visible)
const [modalVisibleFor, setModalVisibleFor] = useState(null);
const [otherLabel, setOtherLabel] = useState(null);
- const chosenLabel = useMemo(() => {
+ const initialLabel = useMemo(() => {
if (modalVisibleFor == null) return null;
- if (otherLabel != null) return 'other';
return labelFor(trip, modalVisibleFor)?.value || null;
- }, [modalVisibleFor, otherLabel]);
+ }, [modalVisibleFor]);
// to mark 'inferred' labels as 'confirmed'; turn yellow labels blue
function verifyTrip() {
@@ -81,16 +83,36 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => {
if (!Object.keys(inputs).length) return displayErrorMsg('No inputs to store');
const inputsToStore: UserInputMap = {};
const storePromises: any[] = [];
- for (let [inputType, chosenLabel] of Object.entries(inputs)) {
+
+ for (let [inputType, newLabel] of Object.entries(inputs)) {
if (isOther) {
/* Let's make the value for user entered inputs look consistent with our other values
(i.e. lowercase, and with underscores instead of spaces) */
- chosenLabel = readableLabelToKey(chosenLabel);
+ newLabel = readableLabelToKey(newLabel);
+ }
+ // If a user saves a new customized label or makes changes to/from customized labels, the labels need to be updated.
+ const key = inputType.toLowerCase();
+ if (
+ isOther ||
+ (initialLabel && customLabelMap[key].indexOf(initialLabel) > -1) ||
+ (newLabel && customLabelMap[key].indexOf(newLabel) > -1)
+ ) {
+ updateUserCustomLabel(key, initialLabel ?? '', newLabel, isOther ?? false)
+ .then((res) => {
+ setCustomLabelMap({
+ ...customLabelMap,
+ [key]: res['label'],
+ });
+ logDebug('Successfuly stored custom label ' + JSON.stringify(res));
+ })
+ .catch((e) => {
+ displayErrorMsg(e, 'Create Label Error');
+ });
}
const inputDataToStore = {
start_ts: trip.start_ts,
end_ts: trip.end_ts,
- label: chosenLabel,
+ label: newLabel,
};
inputsToStore[inputType] = inputDataToStore;
@@ -107,6 +129,8 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => {
}
const tripInputDetails = labelInputDetailsForTrip(userInputFor(trip), appConfig);
+ const customLabelKeyInDatabase = modalVisibleFor === 'PURPOSE' ? 'purpose' : 'mode';
+
return (
<>
@@ -164,16 +188,47 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => {
onChooseLabel(val)}
- value={chosenLabel || ''}>
+ // if 'other' button is selected and input component shows up, make 'other' radio button filled
+ value={otherLabel !== null ? 'other' : initialLabel || ''}>
{modalVisibleFor &&
- labelOptions?.[modalVisibleFor]?.map((o, i) => (
-
- ))}
+ labelOptions?.[modalVisibleFor]?.map((o, i) => {
+ const radioItemForOption = (
+
+ );
+ /* if this is the 'other' option and there are some custom labels,
+ show the custom labels section before 'other' */
+ if (o.value == 'other' && customLabelMap[customLabelKeyInDatabase]?.length) {
+ return (
+ <>
+
+
+ {(modalVisibleFor === 'MODE' ||
+ modalVisibleFor === 'REPLACED_MODE') &&
+ t('trip-confirm.custom-mode')}
+ {modalVisibleFor === 'PURPOSE' && t('trip-confirm.custom-purpose')}
+
+ {customLabelMap[customLabelKeyInDatabase].map((key, i) => (
+
+ ))}
+
+ {radioItemForOption}
+ >
+ );
+ }
+ // otherwise, just show the radio item as normal
+ return radioItemForOption;
+ })}
@@ -185,6 +240,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => {
})}
value={otherLabel || ''}
onChangeText={(t) => setOtherLabel(t)}
+ maxLength={25}
/>