Repository: osmlab/maproulette3
Pull request #1378 (2020-09-02 23:22:49)

Prepare for v3.6.11
### Added
- Info control on challenge results to quickly view basic challenge details
- Show directionality for one-way task features on Task Completion map
- Support for multiple custom user basemaps and overlays
- Allow task status to be changed when resubmitting task after a review
- Option to load more tasks on Nearby Tasks map
- Lasso select/deselect tools on task map when managing a challenge
- Improved task selection behavior when managing a challenge
- Highlight selected tasks on task map when managing a challenge
- Error on z-coordinates in GeoJSON, noting they are unsupported (#1034)
- Error on null GeoJSON geometries, noting they are unsupported (#551)
- Note rebuild-tasks option when editing read-only data source (#1318, #783)
- Make substituted URL property values clickable in task instructions (#1370)

### Fixed
- Misinterpretation of some user mentions as OSM entity short codes
- Broken "Back to Review All" option on task revision confirmation modal
- Unnecessary restriction on max zoom on Nearby Tasks map
- Incorrect project-level average time spent per task
- Stale data sometimes shown on Review-Status Metrics widget

### Removed
- Quick-selection of tasks by status, priority, etc. in challenge managment


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 923592d1..629d373f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,31 @@ The format is based on
 This project adheres to
 [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
 
+## [v3.6.11] - 2020-09-10
+### Added
+- Info control on challenge results to quickly view basic challenge details
+- Show directionality for one-way task features on Task Completion map
+- Support for multiple custom user basemaps and overlays
+- Allow task status to be changed when resubmitting task after a review
+- Option to load more tasks on Nearby Tasks map
+- Lasso select/deselect tools on task map when managing a challenge
+- Improved task selection behavior when managing a challenge
+- Highlight selected tasks on task map when managing a challenge
+- Error on z-coordinates in GeoJSON, noting they are unsupported (#1034)
+- Error on null GeoJSON geometries, noting they are unsupported (#551)
+- Note rebuild-tasks option when editing read-only data source (#1318, #783)
+- Make substituted URL property values clickable in task instructions (#1370)
+
+### Fixed
+- Misinterpretation of some user mentions as OSM entity short codes
+- Broken "Back to Review All" option on task revision confirmation modal
+- Unnecessary restriction on max zoom on Nearby Tasks map
+- Incorrect project-level average time spent per task
+- Stale data sometimes shown on Review-Status Metrics widget
+
+### Removed
+- Quick-selection of tasks by status, priority, etc. in challenge managment
+
 
 ## [v3.6.10] - 2020-08-07
 ### Fixed
diff --git a/package.json b/package.json
index e30687c6..97eb3486 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "maproulette3",
-  "version": "3.6.10",
+  "version": "3.6.11",
   "private": true,
   "dependencies": {
     "@apollo/client": "^3.1.1",
@@ -13,11 +13,15 @@
     "@rjsf/core": "^2.1.0",
     "@turf/bbox": "^6.0.1",
     "@turf/bbox-polygon": "^6.0.1",
+    "@turf/bearing": "^6.0.1",
     "@turf/boolean-disjoint": "^6.0.2",
     "@turf/center": "^6.0.1",
     "@turf/centroid": "^6.0.2",
     "@turf/distance": "^6.0.1",
+    "@turf/helpers": "^6.1.4",
     "@turf/invariant": "^6.1.2",
+    "@turf/meta": "^6.0.2",
+    "@turf/midpoint": "^5.1.5",
     "@turf/nearest-point-on-line": "^6.0.2",
     "bulma": "0.6.0",
     "bulma-badge": "^0.1.0",
@@ -34,7 +38,7 @@
     "handlebars": "^4.7.6",
     "leaflet": "^1.6.0",
     "leaflet-lasso": "^2.1.0",
-    "leaflet-vectoricon": "https://github.com/nrotstan/Leaflet.VectorIcon.git#with-viewbox",
+    "leaflet-vectoricon": "https://github.com/nrotstan/Leaflet.VectorIcon.git#with-transforms",
     "leaflet.markercluster": "^1.4.1",
     "mapillary-js": "^2.20.0",
     "node-sass": "^4.11.0",
diff --git a/src/components/ActivityMap/ActivityMap.js b/src/components/ActivityMap/ActivityMap.js
index b5fd0c42..c3f07bb9 100644
--- a/src/components/ActivityMap/ActivityMap.js
+++ b/src/components/ActivityMap/ActivityMap.js
@@ -11,7 +11,7 @@ import _get from 'lodash/get'
 import _map from 'lodash/map'
 import { latLng } from 'leaflet'
 import { toLatLngBounds, GLOBAL_MAPBOUNDS } from '../../services/MapBounds/MapBounds'
-import { layerSourceWithId } from '../../services/VisibleLayer/LayerSources'
+import { buildLayerSources } from '../../services/VisibleLayer/LayerSources'
 import { TaskStatusColors } from '../../services/Task/TaskStatus/TaskStatus'
 import WithVisibleLayer from '../HOCs/WithVisibleLayer/WithVisibleLayer'
 import EnhancedMap from '../EnhancedMap/EnhancedMap'
@@ -65,8 +65,10 @@ export const ActivityMap = props => {
     })
   }
 
-  const overlayLayers = _map(props.visibleOverlays, (layerId, index) =>
-    <SourcedTileLayer key={layerId} source={layerSourceWithId(layerId)} zIndex={index + 2} />
+  const overlayLayers = buildLayerSources(
+    props.visibleOverlays, _get(props, 'user.settings.customBasemaps'),
+    (layerId, index, layerSource) =>
+      <SourcedTileLayer key={layerId} source={layerSource} zIndex={index + 2} />
   )
 
   if (!coloredMarkers) {
diff --git a/src/components/AdminPane/HOCs/WithChallengeManagement/WithChallengeManagement.js b/src/components/AdminPane/HOCs/WithChallengeManagement/WithChallengeManagement.js
index 283ef0e9..fe812838 100644
--- a/src/components/AdminPane/HOCs/WithChallengeManagement/WithChallengeManagement.js
+++ b/src/components/AdminPane/HOCs/WithChallengeManagement/WithChallengeManagement.js
@@ -211,12 +211,12 @@ const mapDispatchToProps = (dispatch, ownProps) => ({
     return dispatch(bulkUpdateTasks(alteredTasks, true))
   },
 
-  applyBulkTaskStatusChange: (newStatus, challengeId, searchCriteria) => {
-    return dispatch(bulkTaskStatusChange(newStatus, challengeId, searchCriteria))
+  applyBulkTaskStatusChange: (newStatus, challengeId, searchCriteria, excludeTaskIds) => {
+    return dispatch(bulkTaskStatusChange(newStatus, challengeId, searchCriteria, excludeTaskIds))
   },
 
-  removeReviewRequest: (challengeId, taskIds, searchCriteria) => {
-    return dispatch(removeReviewRequest(challengeId, taskIds, searchCriteria))
+  removeReviewRequest: (challengeId, taskIds, searchCriteria, excludeTaskIds) => {
+    return dispatch(removeReviewRequest(challengeId, taskIds, searchCriteria, excludeTaskIds))
   }
 })
 
diff --git a/src/components/AdminPane/HOCs/WithComputedMetrics/WithComputedMetrics.js b/src/components/AdminPane/HOCs/WithComputedMetrics/WithComputedMetrics.js
index c5a85abc..0be0efaf 100644
--- a/src/components/AdminPane/HOCs/WithComputedMetrics/WithComputedMetrics.js
+++ b/src/components/AdminPane/HOCs/WithComputedMetrics/WithComputedMetrics.js
@@ -4,7 +4,7 @@ import _get from 'lodash/get'
 import _isEmpty from 'lodash/isEmpty'
 import _isArray from 'lodash/isArray'
 import _isObject from 'lodash/isObject'
-import _isNumber from 'lodash/isNumber'
+import _isFinite from 'lodash/isFinite'
 import _each from 'lodash/each'
 import _fromPairs from 'lodash/fromPairs'
 import _map from 'lodash/map'
@@ -25,14 +25,21 @@ export default function(WrappedComponent) {
 
     updateTotals = (actions, totalTasks, taskMetrics) => {
       _each(actions, (value, label) => {
-        taskMetrics[label] = _isNumber(taskMetrics[label]) ?
-                            taskMetrics[label] + value :
-                            value
-
-        const percentage = (1.0 * value / totalTasks) * 100.0
-        taskMetrics.percentages[label] =  _isNumber(taskMetrics.percentages[label]) ?
-                                          taskMetrics.percentages[label] + percentage :
-                                          percentage
+        if (label === "avgTimeSpent") {
+          taskMetrics.totalTimeSpent = _isFinite(taskMetrics.totalTimeSpent) ?
+            taskMetrics.totalTimeSpent + (value * actions.tasksWithTime) :
+            value * actions.tasksWithTime
+        }
+        else {
+          taskMetrics[label] = _isFinite(taskMetrics[label]) ?
+                              taskMetrics[label] + value :
+                              value
+
+          const percentage = (1.0 * value / totalTasks) * 100.0
+          taskMetrics.percentages[label] =  _isFinite(taskMetrics.percentages[label]) ?
+                                            taskMetrics.percentages[label] + percentage :
+                                            percentage
+        }
       })
     }
 
diff --git a/src/components/AdminPane/Manage/ChallengeDashboard/ChallengeDashboard.js b/src/components/AdminPane/Manage/ChallengeDashboard/ChallengeDashboard.js
index ddfa05c6..ceda559e 100644
--- a/src/components/AdminPane/Manage/ChallengeDashboard/ChallengeDashboard.js
+++ b/src/components/AdminPane/Manage/ChallengeDashboard/ChallengeDashboard.js
@@ -13,14 +13,14 @@ import WithCurrentChallenge
        from '../../HOCs/WithCurrentChallenge/WithCurrentChallenge'
 import WithWidgetWorkspaces
        from '../../../HOCs/WithWidgetWorkspaces/WithWidgetWorkspaces'
+import WithSelectedClusteredTasks
+       from '../../../HOCs/WithSelectedClusteredTasks/WithSelectedClusteredTasks'
 import WithFilteredClusteredTasks
        from '../../../HOCs/WithFilteredClusteredTasks/WithFilteredClusteredTasks'
 import WithClusteredTasks
       from '../../../HOCs/WithClusteredTasks/WithClusteredTasks'
 import WithChallengeMetrics
        from '../../HOCs/WithChallengeMetrics/WithChallengeMetrics'
-import WithChallengeReviewMetrics
-      from '../../HOCs/WithChallengeReviewMetrics/WithChallengeReviewMetrics'
 import WithSearch from '../../../HOCs/WithSearch/WithSearch'
 import WidgetWorkspace from '../../../WidgetWorkspace/WidgetWorkspace'
 import TaskUploadingProgress
@@ -156,15 +156,15 @@ WithManageableProjects(
     WithSearch(
       WithCurrentChallenge(
         WithWidgetWorkspaces(
-          WithClusteredTasks(
-            WithFilteredClusteredTasks(
-              WithChallengeMetrics(
-                WithChallengeReviewMetrics(
+          WithSelectedClusteredTasks(
+            WithClusteredTasks(
+              WithFilteredClusteredTasks(
+                WithChallengeMetrics(
                   injectIntl(ChallengeDashboard),
-                )
-              ),
-              'clusteredTasks',
-              'filteredClusteredTasks'
+                ),
+                'clusteredTasks',
+                'filteredClusteredTasks'
+              )
             )
           ),
           WidgetDataTarget.challenge,
diff --git a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js
index dc608746..ab90d1fa 100644
--- a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js
+++ b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js
@@ -9,7 +9,6 @@ import _isUndefined from 'lodash/isUndefined'
 import _isFinite from 'lodash/isFinite'
 import _omit from 'lodash/omit'
 import _filter from 'lodash/filter'
-import _first from 'lodash/first'
 import _difference from 'lodash/difference'
 import _get from 'lodash/get'
 import _remove from 'lodash/remove'
@@ -171,7 +170,11 @@ export class EditChallenge extends Component {
     }
     else {
       response.errors = {
-        localGeoJSON: {__errors: _map(lintErrors, e => `GeoJSON error: ${e.message}`)}
+        localGeoJSON: {
+          __errors: _map(lintErrors, e =>
+            _isObject(e.message) ? this.props.intl.formatMessage(e.message) : `GeoJSON error: ${e.message}`
+          )
+        }
       }
     }
 
@@ -188,7 +191,7 @@ export class EditChallenge extends Component {
 
     if (lintErrors.length > 0) {
       response.errors = {
-        overpassQL: this.props.intl.formatMessage(_first(lintErrors).message)
+        overpassQL: {__errors: [this.props.intl.formatMessage(lintErrors[0].message)]}
       }
     }
 
@@ -205,8 +208,8 @@ export class EditChallenge extends Component {
       if (!_isEmpty(this.state.extraErrors)) {
         this.setState({extraErrors: {}})
       }
-    }).catch(errors => {
-      this.setState({extraErrors: errors})
+    }).catch(dataSourceErrors => {
+      this.setState({extraErrors: dataSourceErrors})
     }).finally(() => {
       this.validationPromise = null
     })
diff --git a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Messages.js b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Messages.js
index 164bb80b..653a9c12 100644
--- a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Messages.js
+++ b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Messages.js
@@ -309,6 +309,12 @@ will not be able to make sense of it.
     defaultMessage: "Enter Overpass API query here...",
   },
 
+  overpassQLReadOnly: {
+    id: 'Admin.EditChallenge.form.overpassQL.readOnly',
+    defaultMessage: "Overpass queries cannot be edited, but you can choose Rebuild Tasks when " +
+      "managing your challenge to re-execute the query and freshen up your tasks."
+  },
+
   localGeoJsonLabel: {
     id: 'Admin.EditChallenge.form.localGeoJson.label',
     defaultMessage: "I want to upload a GeoJSON file",
@@ -319,6 +325,12 @@ will not be able to make sense of it.
     defaultMessage: "Please upload the local GeoJSON file from your computer",
   },
 
+  localGeoJsonReadOnly: {
+    id: 'Admin.EditChallenge.form.localGeoJson.readOnly',
+    defaultMessage: "If you need to upload fresh GeoJSON, choose Rebuild Tasks " +
+      "when managing your challenge."
+  },
+
   remoteGeoJsonLabel: {
     id: 'Admin.EditChallenge.form.remoteGeoJson.label',
     defaultMessage: "I have a URL to the GeoJSON data",
@@ -334,6 +346,13 @@ will not be able to make sense of it.
     defaultMessage: "https://www.example.com/geojson.json",
   },
 
+  remoteGeoJsonReadOnly: {
+    id: 'Admin.EditChallenge.form.remoteGeoJson.readOnly',
+    defaultMessage: "Remote URLs cannot be edited, but you can choose Rebuild Tasks " +
+      "when managing your challenge to re-download updated remote GeoJSON and freshen " +
+      "up your tasks."
+  },
+
   dataOriginDateLabel: {
     id: 'Admin.EditChallenge.form.dataOriginDate.label',
     defaultMessage: "Date that data was sourced",
diff --git a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Schemas/DataSourceSchema.js b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Schemas/DataSourceSchema.js
index 65a1473b..95810763 100644
--- a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Schemas/DataSourceSchema.js
+++ b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Schemas/DataSourceSchema.js
@@ -40,7 +40,9 @@ export const jsSchema = (intl, user, challengeData, extraErrors, options={}) =>
       source: { enum: ["Overpass Query"] },
       overpassQL: {
         title: " ",
-        description: intl.formatMessage(messages.overpassQLDescription),
+        description:
+          (sourceReadOnly ? intl.formatMessage(messages.overpassQLReadOnly) + "\n\n" : "") +
+          intl.formatMessage(messages.overpassQLDescription),
         type: "string",
       },
     },
@@ -51,6 +53,7 @@ export const jsSchema = (intl, user, challengeData, extraErrors, options={}) =>
       source: { enum: ["Local File"] },
       localGeoJSON: {
         title: " ",
+        description: sourceReadOnly ? intl.formatMessage(messages.localGeoJsonReadOnly) : undefined,
         type: "string",
       },
       dataOriginDate: {
@@ -67,6 +70,7 @@ export const jsSchema = (intl, user, challengeData, extraErrors, options={}) =>
       source: { enum: ["Remote URL"] },
       remoteGeoJson: {
         title: " ",
+        description: sourceReadOnly ? intl.formatMessage(messages.remoteGeoJsonReadOnly) : undefined,
         type: "string",
       },
       dataOriginDate: {
diff --git a/src/components/AdminPane/Manage/ProjectDashboard/ProjectDashboard.js b/src/components/AdminPane/Manage/ProjectDashboard/ProjectDashboard.js
index 3625d5ab..675cde91 100644
--- a/src/components/AdminPane/Manage/ProjectDashboard/ProjectDashboard.js
+++ b/src/components/AdminPane/Manage/ProjectDashboard/ProjectDashboard.js
@@ -15,8 +15,6 @@ import WithWidgetWorkspaces
        from '../../../HOCs/WithWidgetWorkspaces/WithWidgetWorkspaces'
 import WithDashboardEntityFilter
        from '../../HOCs/WithDashboardEntityFilter/WithDashboardEntityFilter'
-import WithProjectReviewMetrics
-      from '../../HOCs/WithProjectReviewMetrics/WithProjectReviewMetrics'
 import WidgetWorkspace from '../../../WidgetWorkspace/WidgetWorkspace'
 import ChallengeFilterGroup from '../ChallengeFilterGroup/ChallengeFilterGroup'
 import ConfirmAction from '../../../ConfirmAction/ConfirmAction'
@@ -159,8 +157,7 @@ WithManageableProjects(
   WithCurrentProject(
     WithWidgetWorkspaces(
       WithDashboardEntityFilter(
-        WithProjectReviewMetrics(
-          injectIntl(ProjectDashboard)),
+        injectIntl(ProjectDashboard),
         'challenge',
         'challenges',
         'pinnedChallenges',
diff --git a/src/components/AdminPane/Manage/ViewChallengeTasks/ViewChallengeTasks.js b/src/components/AdminPane/Manage/ViewChallengeTasks/ViewChallengeTasks.js
index eadd95f7..ac1af590 100644
--- a/src/components/AdminPane/Manage/ViewChallengeTasks/ViewChallengeTasks.js
+++ b/src/components/AdminPane/Manage/ViewChallengeTasks/ViewChallengeTasks.js
@@ -3,7 +3,6 @@ import PropTypes from 'prop-types'
 import { FormattedMessage } from 'react-intl'
 import { Popup } from 'react-leaflet'
 import _get from 'lodash/get'
-import _map from 'lodash/map'
 import { ChallengeStatus }
        from '../../../../services/Challenge/ChallengeStatus/ChallengeStatus'
 import { TaskStatus,
@@ -55,43 +54,34 @@ export class ViewChallengeTasks extends Component {
     showPriorityBounds: false,
   }
 
-  takeTaskSelectionAction = action => {
-    if (action.statusAction) {
-      this.props.selectTasksWithStatus(action.status)
+  changeStatus = (selectedTasks, newStatus = TaskStatus.created) => {
+    if (!selectedTasks.allSelected && selectedTasks.selected.size === 0) {
+      return // Nothing to do
     }
-    else if (action.priorityAction) {
-      this.props.selectTasksWithPriority(action.priority)
-    }
-  }
-
-  changeStatus = (selectedIds, newStatus = TaskStatus.created) => {
-    if (selectedIds.length === 0) {
-      return
-    }
-    const tasks = _map(selectedIds, id => this.props.selectedTasks.get(id))
 
     this.setState({bulkUpdating: true})
     // If all tasks are selected (so beyond what is being viewed on current page)
-    if (this.props.allTasksAreSelected()) {
+    if (selectedTasks.allSelected) {
       this.props.applyBulkTaskStatusChange(
-        parseInt(newStatus), this.props.challenge.id, this.props.criteria
+        parseInt(newStatus),
+        this.props.challenge.id,
+        this.props.criteria,
+        [...selectedTasks.deselected.keys()]
       ).then(() => {
         this.props.refreshChallenge()
         this.props.refreshTasks()
         this.setState({bulkUpdating: false})
       })
     }
-    // Otherwise only apply to selected tasks
-    else {
-      this.props.applyBulkTaskChanges(
-        tasks, {status: parseInt(newStatus),
-                mappedOn: null,
-                reviewStatus: null,
-                reviewRequestedBy: null,
-                reviewedBy: null,
-                reviewedAt: null}
-
-      ).then(() => {
+    else { // Otherwise only apply to selected tasks
+      this.props.applyBulkTaskChanges([...this.props.selectedTasks.selected.values()], {
+        status: parseInt(newStatus),
+        mappedOn: null,
+        reviewStatus: null,
+        reviewRequestedBy: null,
+        reviewedBy: null,
+        reviewedAt: null
+      }).then(() => {
         this.props.refreshChallenge()
         this.props.refreshTasks()
         this.setState({bulkUpdating: false})
@@ -99,14 +89,18 @@ export class ViewChallengeTasks extends Component {
     }
   }
 
-  removeReviewRequests = (selectedIds) => {
-    if (selectedIds.length === 0) {
-      return
+  removeReviewRequests = selectedTasks => {
+    if (!selectedTasks.allSelected && selectedTasks.selected.size === 0) {
+      return // Nothing to do
     }
-    const selectedTasks = this.props.allTasksAreSelected() ? null : selectedIds
 
     this.setState({bulkUpdating: true})
-    this.props.removeReviewRequest(this.props.challenge.id, selectedTasks, this.props.criteria).then(() => {
+    this.props.removeReviewRequest(
+      this.props.challenge.id,
+      selectedTasks.allSelected ? null : [...selectedTasks.selected.keys()],
+      this.props.criteria,
+      selectedTasks.allSelected ? [...selectedTasks.deselected.keys()] : null
+    ).then(() => {
       this.props.refreshChallenge()
       this.props.refreshTasks()
       this.setState({bulkUpdating: false})
@@ -208,7 +202,10 @@ export class ViewChallengeTasks extends Component {
 
     const map =
         <ClusterMap
+          showLasso
           updateBounds={this.mapBoundsUpdated}
+          onBulkTaskSelection={this.props.selectTasks}
+          onBulkTaskDeselection={this.props.deselectTasks}
           loadingTasks={this.props.loadingTasks}
           showMarkerPopup={this.showMarkerPopup}
           togglePriorityBounds={() => this.setState({showPriorityBounds: !this.state.showPriorityBounds})}
@@ -365,10 +362,11 @@ ViewChallengeTasks.defaultProps = {
   loadingChallenge: false,
 }
 
-export default WithBoundedTasks(
-                 WithTaskPropertyKeys(
-                   WithFilterCriteria(ViewChallengeTasks)
-                 ),
-                 'filteredClusteredTasks',
-                 'taskInfo'
-               )
+export default
+WithBoundedTasks(
+  WithTaskPropertyKeys(
+    WithFilterCriteria(ViewChallengeTasks)
+  ),
+  'filteredClusteredTasks',
+  'taskInfo'
+)
diff --git a/src/components/Bulma/RJSFFormFieldAdapter/RJSFFormFieldAdapter.js b/src/components/Bulma/RJSFFormFieldAdapter/RJSFFormFieldAdapter.js
index c2fe9eda..4ffa4ee5 100644
--- a/src/components/Bulma/RJSFFormFieldAdapter/RJSFFormFieldAdapter.js
+++ b/src/components/Bulma/RJSFFormFieldAdapter/RJSFFormFieldAdapter.js
@@ -59,6 +59,10 @@ export const NoFieldsetObjectFieldTemplate = function(props) {
 }
 
 export const CustomArrayFieldTemplate = props => {
+  const addLabel = props.uiSchema["ui:addLabel"] ||
+    <FormattedMessage {...messages.addPriorityRuleLabel} />
+  const deleteLabel = props.uiSchema["ui:deleteLabel"]
+
   const itemFields = _map(props.items, element =>
     <div
       key={element.index}
@@ -68,14 +72,17 @@ export const CustomArrayFieldTemplate = props => {
         className={classNames({"inline": _get(props, 'uiSchema.items.ui:options.inline')})}
       >
         {element.children}
-
         {element.hasRemove &&
-        <button className="button is-clear array-field__item__control remove-item-button"
-                onClick={element.onDropIndexClick(element.index)}>
-          <span className="icon is-danger">
-            <SvgSymbol sym="trash-icon" viewBox='0 0 20 20' />
-          </span>
-        </button>
+          <button className={classNames(
+            "is-clear array-field__item__control remove-item-button",
+            !deleteLabel ? "button" : "mr-button mr-button mr-button--small")}
+            onClick={element.onDropIndexClick(element.index)}>
+           {deleteLabel ||
+             <span className="icon is-danger">
+               <SvgSymbol sym="trash-icon" viewBox='0 0 20 20' className="mr-w-5 mr-h-5"/>
+             </span>
+           }
+          </button>
         }
       </div>
     </div>
@@ -83,6 +90,9 @@ export const CustomArrayFieldTemplate = props => {
 
   return (
     <div className="array-field">
+      {props.title &&
+        <label className="control-label">{props.title}</label>
+      }
       {itemFields}
       {props.canAdd &&
        <div className="array-field__block-controls">
@@ -90,7 +100,7 @@ export const CustomArrayFieldTemplate = props => {
            className="mr-button mr-button mr-button--small"
            onClick={props.onAddClick}
          >
-           <FormattedMessage {...messages.addPriorityRuleLabel} />
+           {addLabel}
          </button>
        </div>
       }
diff --git a/src/components/CardChallenge/CardChallenge.js b/src/components/CardChallenge/CardChallenge.js
index 2816846b..ce45311c 100644
--- a/src/components/CardChallenge/CardChallenge.js
+++ b/src/components/CardChallenge/CardChallenge.js
@@ -9,9 +9,12 @@ import _isUndefined from 'lodash/isUndefined'
 import _noop from 'lodash/noop'
 import _get from 'lodash/get'
 import _isObject from 'lodash/isObject'
+import _isEmpty from 'lodash/isEmpty'
 import { messagesByDifficulty }
        from '../../services/Challenge/ChallengeDifficulty/ChallengeDifficulty'
 import WithStartChallenge from '../HOCs/WithStartChallenge/WithStartChallenge'
+import Dropdown from '../Dropdown/Dropdown'
+import SvgSymbol from '../SvgSymbol/SvgSymbol'
 import MarkdownContent from '../MarkdownContent/MarkdownContent'
 import Taxonomy from '../Taxonomy/Taxonomy'
 import ChallengeProgress from '../ChallengeProgress/ChallengeProgress'
@@ -58,6 +61,30 @@ export class CardChallenge extends Component {
           {'is-active': this.props.isExpanded}
         )}
       >
+        {!_isEmpty(this.props.info) &&
+         <Dropdown
+           className="mr-float-right mr-dropdown--right"
+           innerClassName="mr-bg-blue-darker"
+           dropdownButton={dropdown => (
+             <button
+               type="button"
+               onClick={dropdown.toggleDropdownVisible}
+               className="mr-ml-4 mr-flex"
+             >
+               <SvgSymbol
+                 sym="info-icon"
+                 viewBox="0 0 20 20"
+                 className="mr-fill-green-lighter mr-w-4 mr-h-4"
+               />
+             </button>
+           )}
+           dropdownContent={dropdown => (
+             <div className="mr-w-96 mr-max-w-screen60 mr-whitespace-normal">
+               {this.props.info}
+             </div>
+           )}
+         />
+        }
         <header className="mr-card-challenge__header" onClick={this.props.cardClicked}>
           <div className="mr-max-w-full">
             <Taxonomy {...this.props} {...this.props.challenge} />
diff --git a/src/components/ChallengePane/ChallengeResultItem/ChallengeResultItem.js b/src/components/ChallengePane/ChallengeResultItem/ChallengeResultItem.js
index 8dea22fb..f3a90248 100644
--- a/src/components/ChallengePane/ChallengeResultItem/ChallengeResultItem.js
+++ b/src/components/ChallengePane/ChallengeResultItem/ChallengeResultItem.js
@@ -4,11 +4,15 @@ import { Link } from 'react-router-dom'
 import _isObject from 'lodash/isObject'
 import _findIndex from 'lodash/findIndex'
 import _isEqual from 'lodash/isEqual'
+import _isFinite from 'lodash/isFinite'
 import _get from 'lodash/get'
 import { FormattedMessage, injectIntl } from 'react-intl'
+import { messagesByDifficulty }
+       from '../../../services/Challenge/ChallengeDifficulty/ChallengeDifficulty'
 import AsManager
        from '../../../interactions/User/AsManager'
 import CardChallenge from '../../CardChallenge/CardChallenge'
+import MarkdownContent from '../../MarkdownContent/MarkdownContent'
 import messages from './Messages'
 import './ChallengeResultItem.scss'
 
@@ -128,6 +132,32 @@ export class ChallengeResultItem extends Component {
           manageControl={manageControl}
           projectQuery={_get(this.props, 'searchFilters.project')}
           excludeProjectId={this.props.excludeProjectId}
+          info={
+            <div className="mr-break-words">
+              {_isFinite(this.props.challenge.difficulty) &&
+               <div className="mr-text-sm">
+                 <strong className="mr-text-yellow mr-uppercase">
+                   <FormattedMessage {...messages.difficulty} />:
+                 </strong>{' '}
+                 <span className="mr-text-white mr-font-medium">
+                   <FormattedMessage
+                     {...messagesByDifficulty[this.props.challenge.difficulty]}
+                   />
+                 </span>
+               </div>
+              }
+              <MarkdownContent markdown={this.props.challenge.description} lightMode={false} />
+              <div>
+                <button
+                  type="button"
+                  onClick={this.browseChallenge}
+                  className="mr-button mr-button--small"
+                >
+                  <FormattedMessage {...messages.browseLabel} />
+                </button>
+              </div>
+            </div>
+          }
         />
       </div>
     )
diff --git a/src/components/ChallengePane/ChallengeResultItem/Messages.js b/src/components/ChallengePane/ChallengeResultItem/Messages.js
index 7cc17423..457c2eca 100644
--- a/src/components/ChallengePane/ChallengeResultItem/Messages.js
+++ b/src/components/ChallengePane/ChallengeResultItem/Messages.js
@@ -4,6 +4,11 @@ import { defineMessages } from 'react-intl'
  * Internationalized messages for use with ChallengeResultItem.
  */
 export default defineMessages({
+  difficulty: {
+    id: 'Challenge.result.difficulty.label',
+    defaultMessage: 'Difficulty',
+  },
+
   unsave: {
     id: 'Challenge.controls.unsave.label',
     defaultMessage: 'Unsave',
@@ -24,6 +29,11 @@ export default defineMessages({
     defaultMessage: "Manage",
   },
 
+  browseLabel: {
+    id: "Challenge.results.controls.browse.label",
+    defaultMessage: "More Details",
+  },
+
   signIn: {
     id: "Challenge.signIn.label",
     defaultMessage: "Sign in to get started",
diff --git a/src/components/ChallengeProgress/ChallengeProgress.js b/src/components/ChallengeProgress/ChallengeProgress.js
index 73f921e6..a7014eef 100644
--- a/src/components/ChallengeProgress/ChallengeProgress.js
+++ b/src/components/ChallengeProgress/ChallengeProgress.js
@@ -240,14 +240,22 @@ export class ChallengeProgress extends Component {
       return null
     }
 
+    let calculatedSeconds = null
     let averageTime = null
-    if (_get(taskActions, 'avgTimeSpent', 0) > 0) {
-      const seconds = taskActions.avgTimeSpent / 1000
+    if (_get(taskActions, 'tasksWithTime', 0) > 0 &&
+        _get(taskActions, 'totalTimeSpent', 0) > 0) {
+      calculatedSeconds = taskActions.totalTimeSpent / taskActions.tasksWithTime / 1000
+    }
+    else if (_get(taskActions, 'avgTimeSpent', 0) > 0) {
+      calculatedSeconds = taskActions.avgTimeSpent / 1000
+    }
+
+    if (calculatedSeconds) {
       averageTime =
         <div className="">
           <FormattedMessage {...messages.avgTimeSpent} />
           <span className="mr-pl-2">
-            {Math.floor(seconds / 60)}m {Math.floor(seconds) % 60}s
+            {Math.floor(calculatedSeconds / 60)}m {Math.floor(calculatedSeconds) % 60}s
           </span>
           {this.props.noteAvgExcludesSkip &&
             <span className="mr-pl-2">
diff --git a/src/components/EnhancedMap/DirectionalIndicationMarker/DirectionalIndicationMarker.js b/src/components/EnhancedMap/DirectionalIndicationMarker/DirectionalIndicationMarker.js
new file mode 100644
index 00000000..624fbd07
--- /dev/null
+++ b/src/components/EnhancedMap/DirectionalIndicationMarker/DirectionalIndicationMarker.js
@@ -0,0 +1,67 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import L from 'leaflet'
+import 'leaflet-vectoricon'
+import { Marker } from 'react-leaflet'
+import { getCoord } from '@turf/invariant'
+import bearing from '@turf/bearing'
+import midpoint from '@turf/midpoint'
+import _isFinite from 'lodash/isFinite'
+import resolveConfig from 'tailwindcss/resolveConfig'
+import tailwindConfig from '../../../tailwind.config.js'
+
+const colors = resolveConfig(tailwindConfig).theme.colors
+
+/*
+ * react-leaflet Marker with arrow icon that is rotated to indicate
+ * directionality. If a heading and leaflet LatLng position are given, it will
+ * be rendered with those. Alternatively, if betweenPoints is given with an
+ * array of two GeoJSON Points, it will be rendered facing from point 1 towards
+ * point 2 and can be automatically positioned at the midpoint between the two
+ * if atMidpoint is given (otherwise the position prop will be used)
+ */
+export const DirectionalIndicationMarker = function(props) {
+  if (_isFinite(props.heading)) {
+    const icon = createDirectionalIndicatorIcon(props.heading, props.styles)
+    return <Marker position={props.position} icon={icon} />
+  }
+  else if (props.betweenPoints) {
+    const heading = bearing(props.betweenPoints[0], props.betweenPoints[1])
+    const icon = createDirectionalIndicatorIcon(heading, props.styles)
+    const position = props.atMidpoint ?
+      getCoord(midpoint(props.betweenPoints[0], props.betweenPoints[1])) :
+      props.position
+    return <Marker position={{lon: position[0], lat: position[1]}} icon={icon} />
+  }
+  else {
+    throw new Error("Either heading or betweenPoints props is required")
+  }
+}
+
+DirectionalIndicationMarker.propTypes = {
+  heading: PropTypes.number,
+  betweenPoints: PropTypes.array,
+  atMidpoint: PropTypes.bool,
+}
+
+export const createDirectionalIndicatorIcon = function(heading, styles={}) {
+  const icon = L.vectorIcon({
+    className: 'directional-marker-icon',
+    viewBox: '0 0 20 20',
+    svgHeight: 20,
+    svgWidth: 20,
+    type: 'path',
+    shape: { // zondicons "arrow-thick-up" icon (shape must point north by default)
+      d: "M7 10v8h6v-8h5l-8-8-8 8h5z",
+    },
+    style: {
+      fill: colors.black,
+      strokeWidth: 0,
+      transform: `rotate(${heading} 10 10)`,
+    }
+  })
+
+  return icon
+}
+
+export default DirectionalIndicationMarker
diff --git a/src/components/EnhancedMap/LassoSelectionControl/LassoSelectionControl.js b/src/components/EnhancedMap/LassoSelectionControl/LassoSelectionControl.js
index 18a0bc66..40aa79d5 100644
--- a/src/components/EnhancedMap/LassoSelectionControl/LassoSelectionControl.js
+++ b/src/components/EnhancedMap/LassoSelectionControl/LassoSelectionControl.js
@@ -1,5 +1,6 @@
 import React from 'react'
 import ReactDOM from 'react-dom'
+import classNames from 'classnames'
 import L from 'leaflet'
 import 'leaflet-lasso'
 import { injectIntl } from 'react-intl'
@@ -17,23 +18,49 @@ import SvgSymbol from '../../SvgSymbol/SvgSymbol'
 const LassoSelectionLeafletControl = L.Control.extend({
   onAdd: function(map) {
     const lasso = L.lasso(map, {})
+    let deselecting = false
 
     map.on('lasso.finished', (event) => {
+      deselecting ?
+      this.options.onLassoDeselection(event.layers) :
       this.options.onLassoSelection(event.layers)
     })
 
     // build the control button, render it, and return it
     const controlContent = (
-      <button
-        onClick={() => lasso.toggle()}
-        className="mr-leading-none mr-p-2 mr-bg-black-50 mr-text-white mr-w-8 mr-h-8 mr-flex mr-items-center mr-shadow mr-rounded-sm mr-transition-normal-in-out-quad hover:mr-text-green-lighter"
-      >
-        <SvgSymbol
-          sym="lasso-icon"
-          className="mr-w-4 mr-h-4 mr-fill-current"
-          viewBox="0 0 512 512"
-        />
-      </button>
+      <React.Fragment>
+        <button
+          onClick={() => {
+            deselecting = false
+            lasso.toggle()
+          }}
+          className={classNames(
+            "mr-leading-none mr-p-2 mr-bg-black-50 mr-text-white mr-w-8 mr-h-8 mr-flex mr-items-center mr-transition-normal-in-out-quad hover:mr-text-green-lighter",
+            this.options.onLassoDeselection ? "mr-rounded-t-sm mr-border-b mr-border-white-15" : "mr-rounded-sm"
+          )}
+        >
+          <SvgSymbol
+            sym={this.options.onLassoDeselection ? "lasso-add-icon" : "lasso-icon"}
+            className="mr-w-4 mr-h-4 mr-fill-current mr-stroke-current"
+            viewBox="0 0 512 512"
+          />
+        </button>
+        {this.options.onLassoDeselection &&
+        <button
+          onClick={() => {
+            deselecting = true
+            lasso.toggle()
+          }}
+          className="mr-leading-none mr-p-2 mr-bg-black-50 mr-text-white mr-w-8 mr-h-8 mr-flex mr-items-center mr-shadow mr-rounded-b-sm mr-transition-normal-in-out-quad hover:mr-text-green-lighter"
+        >
+          <SvgSymbol
+            sym="lasso-remove-icon"
+            className="mr-w-4 mr-h-4 mr-fill-current mr-stroke-current"
+            viewBox="0 0 512 512"
+          />
+        </button>
+        }
+      </React.Fragment>
     )
 
     const controlContainer = L.DomUtil.create('div')
diff --git a/src/components/EnhancedMap/SearchControl/SearchControl.js b/src/components/EnhancedMap/SearchControl/SearchControl.js
index 1421c159..50575c00 100644
--- a/src/components/EnhancedMap/SearchControl/SearchControl.js
+++ b/src/components/EnhancedMap/SearchControl/SearchControl.js
@@ -1,6 +1,9 @@
-import React, { Component } from 'react'
+import React from 'react'
+import ReactDOM from 'react-dom'
+import L from 'leaflet'
+import { MapControl, withLeaflet } from 'react-leaflet'
 import _map from 'lodash/map'
-import { FormattedMessage } from 'react-intl'
+import { FormattedMessage, injectIntl } from 'react-intl'
 import WithNominatimSearch from '../../HOCs/WithNominatimSearch/WithNominatimSearch'
 import SvgSymbol from '../../SvgSymbol/SvgSymbol'
 import BusySpinner from '../../BusySpinner/BusySpinner'
@@ -8,104 +11,126 @@ import Dropdown from '../../Dropdown/Dropdown'
 import messages from './Messages'
 
 /**
- * SearchControl presents a map control that can be used to execute geographic
- * searches via Nominatim (name searches, lon/lat searches, etc.)
+ * SearchControl presents a Leaflet control that can be used to execute
+ * geographic searches via Nominatim (name searches, lon/lat searches, etc.)
  *
  * @author [Neil Rotstan](https://github.com/nrotstan)
  */
-export class SearchControl extends Component {
-  /**
-   * Esc clears search, Enter signals completion
-   *
-   * @private
-   */
-  checkForSpecialKeys = (e) => {
+export class SearchControl extends MapControl {
+  // props will be available as `options` field in the leaflet control
+  createLeafletElement(props) {
+    return new SearchLeafletControl(props)
+  }
+
+  // Re-render the control content when this component is rerendered
+  updateLeafletElement(fromProps, toProps) {
+    ReactDOM.render(ControlContent(toProps), this.leafletElement.getContainer())
+    return this.leafletElement
+  }
+}
+
+/**
+ * The actual Leaflet control, which simply performs an initial rendering of
+ * the control content when the control is added to the map
+ */
+const SearchLeafletControl = L.Control.extend({
+  onAdd: function(map) {
+    const controlContainer = L.DomUtil.create('div')
+    ReactDOM.render(ControlContent(this.options), controlContainer)
+    return controlContainer
+  }
+})
+
+/**
+ * Component that renders the actual control content
+ */
+const ControlContent = props => {
+  const checkForSpecialKeys = e => {
+    // Esc clears search, Enter signals completion
     if (e.key === "Escape") {
-      this.props.clearNominatimSearch()
+      props.clearNominatimSearch()
     }
     else if (e.key === "Enter") {
-      this.props.searchNominatim()
+      props.searchNominatim()
     }
   }
 
-  render() {
-    const resultItems = _map(this.props.nominatimResults, result => (
-      <li key={result.osmId}>
+  const resultItems = _map(props.nominatimResults, result => (
+    <li key={result.osmId}>
+      <button
+        className='mr-text-current hover:mr-text-yellow'
+        onClick={() => props.chooseNominatimResult(result)}
+      >
+        {result.name}
+      </button>
+    </li>
+  ))
+
+  return (
+    <Dropdown
+      className="mr-dropdown--right"
+      dropdownButton={dropdown =>
         <button
-          className='mr-text-current hover:mr-text-yellow'
-          onClick={() => this.props.chooseNominatimResult(result)}
+          onClick={dropdown.toggleDropdownVisible}
+          className="mr-leading-none mr-p-2 mr-bg-black-50 mr-text-white mr-w-8 mr-h-8 mr-flex mr-items-center mr-shadow mr-rounded-sm mr-transition-normal-in-out-quad hover:mr-text-green-lighter"
+          aria-haspopup="true"
+          aria-controls="dropdown-menu"
         >
-          {result.name}
+          <SvgSymbol sym="search-icon" className="mr-w-4 mr-h-4 mr-fill-current" viewBox="0 0 20 20" />
         </button>
-      </li>
-    ))
-
-    return (
-      <Dropdown
-        className="mr-dropdown--right mr-absolute mr-z-5 mr-right-0 mr-top-0 mr-mr-2 mr-mt-41"
-        dropdownButton={dropdown =>
-          <button
-            onClick={dropdown.toggleDropdownVisible}
-            className="mr-leading-none mr-p-2 mr-bg-black-50 mr-text-white mr-w-8 mr-h-8 mr-flex mr-items-center mr-shadow mr-rounded-sm mr-transition-normal-in-out-quad hover:mr-text-green-lighter"
-            aria-haspopup="true"
-            aria-controls="dropdown-menu"
-          >
-            <SvgSymbol sym="search-icon" className="mr-w-4 mr-h-4 mr-fill-current" viewBox="0 0 20 20" />
-          </button>
-        }
-        dropdownContent={dropdown =>
-          <div className="mr-min-w-102">
-            <div className="mr-flex mr-justify-between mr-items-center">
-              <div className="mr-flex mr-items-center">
-                <input
-                  className="mr-input mr-input--green-lighter-outline"
-                  type="text"
-                  placeholder={this.props.intl.formatMessage(messages.nominatimQuery)}
-                  value={this.props.nominatimQuery}
-                  onChange={(e) => this.props.updateNominatimQuery(e.target.value)}
-                  onKeyDown={this.checkForSpecialKeys}
-                />
-                {this.props.nominatumSearching ?
-                 <BusySpinner inline className="mr-static" /> :
-                 <button
-                   className="mr-button mr-h-10 mr-py-0 mr-ml-4"
-                   onClick={this.props.searchNominatim}
-                 >
-                   <FormattedMessage {...messages.searchLabel } />
-                 </button>
-                }
-              </div>
-              <button
-                className="mr-ml-4"
-                onClick={() => {
-                  dropdown.closeDropdown()
-                  this.props.clearNominatimSearch()
-                }}
-              >
-                <SvgSymbol
-                  sym="icon-close"
-                  viewBox="0 0 20 20"
-                  className="mr-fill-white mr-w-4 mr-h-4"
-                  aria-hidden
-                />
-              </button>
+      }
+      dropdownContent={dropdown =>
+        <div className="mr-min-w-102">
+          <div className="mr-flex mr-justify-between mr-items-center">
+            <div className="mr-flex mr-items-center">
+              <input
+                className="mr-input mr-input--green-lighter-outline mr-bg-black-25"
+                type="text"
+                placeholder={props.intl.formatMessage(messages.nominatimQuery)}
+                value={props.nominatimQuery}
+                onChange={(e) => props.updateNominatimQuery(e.target.value)}
+                onKeyDown={checkForSpecialKeys}
+              />
+              {props.nominatumSearching ?
+                <BusySpinner inline className="mr-static" /> :
+                <button
+                  className="mr-button mr-h-10 mr-py-0 mr-ml-4"
+                  onClick={props.searchNominatim}
+                >
+                  <FormattedMessage {...messages.searchLabel } />
+                </button>
+              }
             </div>
-
-            {this.props.nominatimResults &&
-             <React.Fragment>
-               <hr className="mr-h-px mr-my-4 mr-bg-blue" />
-               {resultItems.length === 0 ? <FormattedMessage {...messages.noResults } /> :
-                <ol className="mr-o-2" onClick={() => dropdown.closeDropdown()}>
-                  {resultItems}
-                </ol>
-               }
-             </React.Fragment>
-            }
+            <button
+              className="mr-ml-4"
+              onClick={() => {
+                dropdown.closeDropdown()
+                props.clearNominatimSearch()
+              }}
+            >
+              <SvgSymbol
+                sym="icon-close"
+                viewBox="0 0 20 20"
+                className="mr-fill-white mr-w-4 mr-h-4"
+                aria-hidden
+              />
+            </button>
           </div>
-        }
-      />
-    )
-  }
+
+          {props.nominatimResults &&
+            <React.Fragment>
+              <hr className="mr-h-px mr-my-4 mr-bg-blue" />
+              {resultItems.length === 0 ? <FormattedMessage {...messages.noResults } /> :
+              <ol className="mr-o-2" onClick={() => dropdown.closeDropdown()}>
+                {resultItems}
+              </ol>
+              }
+            </React.Fragment>
+          }
+        </div>
+      }
+    />
+  )
 }
 
-export default WithNominatimSearch(SearchControl)
+export default WithNominatimSearch(withLeaflet(injectIntl(SearchControl)))
diff --git a/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.js b/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.js
index 0aec38cf..19617a6e 100644
--- a/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.js
+++ b/src/components/HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters.js
@@ -1,6 +1,8 @@
 import React, { Component } from 'react'
 import { connect } from 'react-redux'
 import { bindActionCreators } from 'redux'
+import bboxPolygon from '@turf/bbox-polygon'
+import booleanDisjoint from '@turf/boolean-disjoint'
 import _omit from 'lodash/omit'
 import _cloneDeep from 'lodash/cloneDeep'
 import _get from 'lodash/get'
@@ -8,15 +10,18 @@ import _isEqual from 'lodash/isEqual'
 import _uniqueId from 'lodash/uniqueId'
 import _sum from 'lodash/sum'
 import _map from 'lodash/map'
+import _filter from 'lodash/filter'
 import _set from 'lodash/set'
 import _debounce from 'lodash/debounce'
+import _isEmpty from 'lodash/isEmpty'
+import _isFinite from 'lodash/isFinite'
 import { fromLatLngBounds, boundsWithinAllowedMaxDegrees }
        from '../../../services/MapBounds/MapBounds'
 import { fetchTaskClusters, clearTaskClusters }
        from '../../../services/Task/TaskClusters'
 import { fetchBoundedTasks, clearBoundedTasks }
        from '../../../services/Task/BoundedTask'
-
+import AsMappableTask from '../../../interactions/Task/AsMappableTask'
 import { MAX_ZOOM, UNCLUSTER_THRESHOLD } from '../../TaskClusterMap/TaskClusterMap'
 
 /**
@@ -25,21 +30,28 @@ import { MAX_ZOOM, UNCLUSTER_THRESHOLD } from '../../TaskClusterMap/TaskClusterM
  *
  * @author [Kelli Rotstan](https://github.com/krotstan)
  */
-export const WithChallengeTaskClusters = function(WrappedComponent, storeTasks=false) {
+export const WithChallengeTaskClusters = function(WrappedComponent, storeTasks=false, showClusters=true) {
   return class extends Component {
     state = {
       loading: false,
       fetchId: null,
       clusters: {},
-      showAsClusters: true,
+      showAsClusters: showClusters,
       taskCount: 0
     }
 
     updateBounds = (bounds, zoom, fromUserAction=false) => {
-      if (this.props.criteria.boundingBox !== fromLatLngBounds(bounds).join(',')) {
+      const arrayBounds = fromLatLngBounds(bounds)
+      if (this.props.criteria.boundingBox !== arrayBounds.join(',')) {
         const criteria = _cloneDeep(this.props.criteria)
-        criteria.boundingBox = fromLatLngBounds(bounds).join(',')
+        criteria.boundingBox = arrayBounds.join(',')
         this.props.updateTaskFilterBounds(bounds, zoom, fromUserAction)
+
+        // If task selection is active, prune any selections that no longer
+        // intersect with the new map bounds
+        this.props.pruneSelectedTasks && this.props.pruneSelectedTasks(task =>
+          booleanDisjoint(AsMappableTask(task).normalizedGeometries(), bboxPolygon(arrayBounds))
+        )
       }
     }
 
@@ -150,8 +162,34 @@ export const WithChallengeTaskClusters = function(WrappedComponent, storeTasks=f
       }
     }
 
+    clustersAsTasks = () => {
+      if (_isEmpty(this.state.clusters)) {
+        return this.state.clusters
+      }
+
+      // Sometimes we have tasks, sometimes single-point clusters depending on
+      // whether tasks have been unclustered. Either way, represent as tasks
+      return _isFinite(this.state.clusters[0].clusterId) ? // clusters
+             _map(this.state.clusters, cluster => ({
+               id: cluster.taskId,
+               status: cluster.taskStatus,
+               priority: cluster.taskPriority,
+               parentId: cluster.challengeIds[0],
+               geometries: cluster.geometries,
+             })) :
+             this.state.clusters // tasks
+    }
+
     onBulkTaskSelection = taskIds => {
-      this.props.onBulkTaskSelection(taskIds, this.state.clusters)
+      const tasks =
+        _filter(this.clustersAsTasks(), task => taskIds.indexOf(task.id) !== -1)
+      this.props.onBulkTaskSelection(tasks)
+    }
+
+    onBulkTaskDeselection = taskIds => {
+      const tasks =
+        _filter(this.clustersAsTasks(), task => taskIds.indexOf(task.id) !== -1)
+      this.props.onBulkTaskDeselection(tasks)
     }
 
     render() {
@@ -165,6 +203,7 @@ export const WithChallengeTaskClusters = function(WrappedComponent, storeTasks=f
           boundingBox={criteriaBounds}
           updateBounds = {this.updateBounds}
           onBulkTaskSelection = {this.onBulkTaskSelection}
+          onBulkTaskDeselection = {this.onBulkTaskDeselection}
           loading = {this.state.loading}
           toggleShowAsClusters = {this.toggleShowAsClusters}
           showAsClusters = {this.state.showAsClusters}
@@ -187,5 +226,5 @@ export const mapDispatchToProps = dispatch => Object.assign(
   }
 )
 
-export default (WrappedComponent, storeTasks) =>
-  connect(null, mapDispatchToProps)(WithChallengeTaskClusters(WrappedComponent, storeTasks))
+export default (WrappedComponent, storeTasks, showClusters) =>
+  connect(null, mapDispatchToProps)(WithChallengeTaskClusters(WrappedComponent, storeTasks, showClusters))
diff --git a/src/components/HOCs/WithFilteredClusteredTasks/WithFilteredClusteredTasks.js b/src/components/HOCs/WithFilteredClusteredTasks/WithFilteredClusteredTasks.js
index 715a3740..e31924de 100644
--- a/src/components/HOCs/WithFilteredClusteredTasks/WithFilteredClusteredTasks.js
+++ b/src/components/HOCs/WithFilteredClusteredTasks/WithFilteredClusteredTasks.js
@@ -5,14 +5,10 @@ import _filter from 'lodash/filter'
 import _fromPairs from 'lodash/fromPairs'
 import _isEmpty from 'lodash/isEmpty'
 import _isArray from 'lodash/isArray'
-import _differenceBy from 'lodash/differenceBy'
 import _omit from 'lodash/omit'
 import _isUndefined from 'lodash/isUndefined'
-import _find from 'lodash/find'
 import _isEqual from 'lodash/isEqual'
 import _isFinite from 'lodash/isFinite'
-import _each from 'lodash/each'
-import _merge from 'lodash/merge'
 import _cloneDeep from 'lodash/cloneDeep'
 import _assignWith from 'lodash/assignWith'
 import { TaskStatus } from '../../../services/Task/TaskStatus/TaskStatus'
@@ -51,12 +47,9 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
     }
 
     state = Object.assign({}, this.defaultFilters(), {
-      selectedTasks: new Map(),
       filteredTasks: {tasks: []},
-      allSelected: false,
     })
 
-
     /**
      * Toggle filtering on or off for the given task status
      */
@@ -77,19 +70,18 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
                                              this.state.includeReviewStatuses,
                                              this.state.includePriorities,
                                              this.state.includeLocked)
+      this.setState({includeStatuses, filteredTasks})
 
-      const allFilteredTasks = this.filterTasks(includeStatuses,
-                                             this.state.includeReviewStatuses,
-                                             this.state.includePriorities,
-                                             this.state.includeLocked, true)
-      const selectedTasks = this.unselectExcludedTasks(allFilteredTasks)
-
-      this.setState({
-        includeStatuses,
-        selectedTasks,
-        filteredTasks,
-        allSelected: false,
-      })
+      // If task selection is active, prune any selections that no longer pass filters
+      this.props.pruneSelectedTasks && this.props.pruneSelectedTasks(task =>
+        !this.taskPassesFilters(
+          task,
+          includeStatuses,
+          this.state.includeReviewStatuses,
+          this.state.includePriorities,
+          this.state.includeLocked
+        )
+      )
     }
 
     /**
@@ -113,18 +105,18 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
                                              this.state.includePriorities,
                                              this.state.includeLocked)
 
-      const allFilteredTasks = this.filterTasks(this.state.includeStatuses,
-                                             includeReviewStatuses,
-                                             this.state.includePriorities,
-                                             this.state.includeLocked, true)
-      const selectedTasks = this.unselectExcludedTasks(allFilteredTasks)
+      this.setState({includeReviewStatuses, filteredTasks})
 
-      this.setState({
-        includeReviewStatuses,
-        selectedTasks,
-        filteredTasks,
-        allSelected: false
-      })
+      // If task selection is active, prune any selections that no longer pass filters
+      this.props.pruneSelectedTasks && this.props.pruneSelectedTasks(task =>
+        !this.taskPassesFilters(
+          task,
+          this.state.includeStatuses,
+          includeReviewStatuses,
+          this.state.includePriorities,
+          this.state.includeLocked
+        )
+      )
     }
 
     /**
@@ -148,42 +140,32 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
                                              includePriorities,
                                              this.state.includeLocked)
 
-      const allFilteredTasks = this.filterTasks(this.state.includeStatuses,
-                                             this.state.includeReviewStatuses,
-                                             includePriorities,
-                                             this.state.includeLocked, true)
-      const selectedTasks = this.unselectExcludedTasks(allFilteredTasks)
-      this.setState({
-        includePriorities,
-        selectedTasks,
-        filteredTasks,
-        allSelected: false
-      })
+      this.setState({includePriorities, filteredTasks})
+
+      // If task selection is active, prune any selections that no longer pass filters
+      this.props.pruneSelectedTasks && this.props.pruneSelectedTasks(task =>
+        !this.taskPassesFilters(
+          task,
+          this.state.includeStatuses,
+          this.state.includeReviewStatuses,
+          includePriorities,
+          this.state.includeLocked
+        )
+      )
     }
 
     /**
      * Filters the tasks, returning only those that match both the given
      * statuses and priorites.
      */
-    filterTasks = (includeStatuses, includeReviewStatuses, includePriorities, includeLocked,
-                   includeAll = false) => {
-      let results = null
+    filterTasks = (includeStatuses, includeReviewStatuses, includePriorities, includeLocked) => {
+      let results = {tasks: []}
       let tasks = _cloneDeep(_get(this.props[tasksProp], 'tasks'))
       if (_isArray(tasks)) {
-        if (includeAll) {
-          // In order to include all, we need to include any that are in the
-          // selectedTasks that were not in props[tasksProps]
-          // (props[taskProps] is limited to only current page of results)
-          _merge(tasks, Array.from(this.state.selectedTasks.values()))
-        }
-
         results = Object.assign({}, this.props[tasksProp], {
           tasks: _filter(tasks, task =>
-            includeStatuses[task.status] && includePriorities[task.priority] &&
-            ((_isUndefined(task.reviewStatus) && includeReviewStatuses[REVIEW_STATUS_NOT_SET]) ||
-              includeReviewStatuses[task.reviewStatus]) &&
-            (includeLocked || !_isFinite(task.lockedBy) || task.lockedBy === _get(this.props, 'user.id'))
-          ),
+            this.taskPassesFilters(task, includeStatuses, includeReviewStatuses, includePriorities, includeLocked)
+          )
         })
       }
 
@@ -191,219 +173,52 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
     }
 
     /**
-     * Toggle selection of the given task on or off
-     */
-    toggleTaskSelection = task => {
-      const selected = new Map(this.state.selectedTasks)
-      if (selected.has(task.id)) {
-        selected.delete(task.id)
-      }
-      else {
-        selected.set(task.id, task)
-      }
-
-      this.setState({selectedTasks: selected, allSelected: false})
-    }
-
-    /**
-     * Toggle selection of the task with the given id on or off. Requires a
-     * search of the tasks, so less optimal than toggleTaskSelection
-     */
-    toggleTaskSelectionById = taskId => {
-      const selected = new Map(this.state.selectedTasks)
-      if (selected.has(taskId)) {
-        selected.delete(taskId)
-      }
-      else {
-        const task = _find(_get(this.props[tasksProp], 'tasks', []), {id: taskId})
-        if (task) {
-          selected.set(task.id, task)
-        }
-      }
-
-      this.setState({selectedTasks: selected, allSelected: false})
-    }
-
-    /**
-     * Select multiple tasks matching the given task ids
-     */
-    selectTasksById = (taskIds, allTasks) => {
-      if (_isEmpty(taskIds)) {
-        return
-      }
-
-      const selected = new Map(this.state.selectedTasks)
-      const tasks = _filter(allTasks,
-                            task => taskIds.indexOf(task.id) !== -1)
-
-      _each(tasks, task => selected.set(task.id, task))
-      this.setState({selectedTasks: selected, allSelected: false})
-    }
-
-    /**
-     * Returns true if all tasks are selected, false if not. This method
-     * simply compares the number of selected tasks and doesn't actually
-     * check each task individually.
-     */
-    allTasksAreSelected = (excludeAllSelected = false) => {
-      if (this.state.selectedTasks.size === 0) {
-        return false
-      }
-
-      const totalTaskCount = _get(this.state, 'filteredTasks.tasks.length', 0)
-      return this.state.selectedTasks.size === totalTaskCount && this.state.allSelected
-    }
-
-    /**
-     * Returns true if some, but not all, tasks are selected.
+     * Determines if the given task passes all active filters, returning true if it does
      */
-    someTasksAreSelected = () => {
-      return this.state.selectedTasks.size > 0 && !this.allTasksAreSelected()
+    taskPassesFilters = (task, includeStatuses, includeReviewStatuses, includePriorities, includeLocked) => {
+      return (
+        includeStatuses[task.status] && includePriorities[task.priority] &&
+        ((_isUndefined(task.reviewStatus) && includeReviewStatuses[REVIEW_STATUS_NOT_SET]) ||
+          includeReviewStatuses[task.reviewStatus]) &&
+        (includeLocked || !_isFinite(task.lockedBy) || task.lockedBy === _get(this.props, 'user.id'))
+      )
     }
 
-    /**
-     * Removes from the currently selected tasks any tasks that are no longer
-     * present in the given filtered tasks.
-     *
-     * @private
-     */
-    unselectExcludedTasks = filteredTasks => {
-      const selected = new Map(this.state.selectedTasks)
-      const excludedTasks = _differenceBy([...selected.values()],
-                                          filteredTasks.tasks,
-                                          task => task.id)
+    clearAllFilters = () => {
+      const freshFilters = this.defaultFilters()
+      const filteredTasks = this.filterTasks(freshFilters.includeStatuses,
+                                             freshFilters.includeReviewStatuses,
+                                             freshFilters.includePriorities,
+                                             freshFilters.includeLocked)
 
-      for (let i = 0; i < excludedTasks.length; i++) {
-        selected.delete(excludedTasks[i].id)
-      }
+      this.setState(Object.assign({filteredTasks}, freshFilters))
 
-      return selected
+      // Reset any task selections as well
+      this.props.resetSelectedTasks && this.props.resetSelectedTasks()
     }
 
     /**
-     * Refresh the selected tasks to ensure they don't include tasks that don't
-     * pass the current filters. This is intended for wrapped components to use
-     * if they do something that alters the status of tasks.
+     * Refresh the currently filtered tasks, intended to be used after task
+     * statuses or other filterable criteria have been changed
      */
-    refreshSelectedTasks = () => {
+    refreshFilteredTasks = () => {
       const filteredTasks = this.filterTasks(this.state.includeStatuses,
                                              this.state.includeReviewStatuses,
                                              this.state.includePriorities,
                                              this.state.includeLocked)
 
-      const allfilteredTasks = this.filterTasks(this.state.includeStatuses,
-                                                this.state.includeReviewStatuses,
-                                                this.state.includePriorities,
-                                                this.state.includeLocked,
-                                                true)
-      const selectedTasks = this.unselectExcludedTasks(allfilteredTasks)
-      const allSelected = this.state.allSelected &&
-        selectedTasks.size === _get(this.state, 'filteredTasks.tasks.length', 0)
-
-      this.setState({filteredTasks, selectedTasks, allSelected})
-    }
-
-    /**
-     * Toggle selection of all the tasks on or off. If not all tasks are
-     * currently selected, then all will be selected; if all were selected then
-     * all will be unselected.
-     */
-    toggleAllTasksSelection = () => {
-      const selected = new Map(this.state.selectedTasks)
-      let allSelected = false
-
-      if (this.allTasksAreSelected()) {
-        selected.clear()
-      }
-      else {
-        let task = null
-
-        selected.clear()
-        for (let i = 0; i < this.state.filteredTasks.tasks.length; i++) {
-          task = this.state.filteredTasks.tasks[i]
-          selected.set(task.id, task)
-        }
-        allSelected = true
-      }
-
-      this.setState({selectedTasks: selected, allSelected})
-    }
-
-    /**
-     * All will be unselected.
-     */
-    unselectAllTasks = () => {
-      const selected = new Map(this.state.selectedTasks)
-      selected.clear()
-      this.setState({selectedTasks: selected, allSelected: false})
-    }
-
-    /**
-     * Select all filtered tasks that match the given status.
-     */
-    selectTasksWithStatus = status => {
-      const selected = new Map(this.state.selectedTasks)
-      let task = null
-
-      for (let i = 0; i < this.state.filteredTasks.tasks.length; i++) {
-        task = this.state.filteredTasks.tasks[i]
-        if (task.status === status) {
-          selected.set(task.id, task)
-        }
-      }
-
-      this.setState({selectedTasks: selected})
-    }
-
-    /**
-     * Select all filtered tasks that match the given status.
-     */
-    selectTasksWithPriority = priority => {
-      const selected = new Map(this.state.selectedTasks)
-      let task = null
-
-      for (let i = 0; i < this.state.filteredTasks.tasks.length; i++) {
-        task = this.state.filteredTasks.tasks[i]
-        if (task.priority === priority) {
-          selected.set(task.id, task)
-        }
-      }
-
-      this.setState({selectedTasks: selected, allSelected: false})
-    }
-
-    /**
-     * Select the given tasks. No checks are made to ensure the tasks are
-     * actually included in the filtered results, so take care with this method
-     */
-    selectTasks = tasks => {
-      const selected = new Map(this.state.selectedTasks)
-      for (let i = 0; i < tasks.length; i++) {
-        selected.set(tasks[i].id, tasks[i])
-      }
-
-      this.setState({selectedTasks: selected, allSelected: false})
-    }
-
-    clearAllFilters = () => {
-      const freshFilters = this.defaultFilters()
-      const filteredTasks = this.filterTasks(freshFilters.includeStatuses,
-                                             freshFilters.includeReviewStatuses,
-                                             freshFilters.includePriorities,
-                                             freshFilters.includeLocked)
-
-      const allFilteredTasks = this.filterTasks(freshFilters.includeStatuses,
-                                             freshFilters.includeReviewStatuses,
-                                             freshFilters.includePriorities,
-                                             freshFilters.includeLocked, true)
-      const selectedTasks = this.unselectExcludedTasks(allFilteredTasks)
-
-      this.setState(Object.assign({filteredTasks, selectedTasks}, freshFilters))
-    }
+      this.setState({filteredTasks})
 
-    resetSelectedTasks = () => {
-      this.clearAllFilters()
-      this.setState({selectedTasks: new Map(), allSelected: false})
+      // If task selection is active, prune any selections that no longer pass filters
+      this.props.pruneSelectedTasks && this.props.pruneSelectedTasks(task =>
+        !this.taskPassesFilters(
+          task,
+          this.state.includeStatuses,
+          this.state.includeReviewStatuses,
+          this.state.includePriorities,
+          this.state.includeLocked
+        )
+      )
     }
 
     componentDidMount() {
@@ -412,25 +227,12 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
                                              this.state.includePriorities,
                                              this.state.includeLocked)
       this.setState({filteredTasks})
-
-      // If we have a property that wants the accessor to unselect all tasks
-      // then set it up.
-      if (this.props.setResetSelectedTasksAccessor) {
-        this.props.setResetSelectedTasksAccessor(() => this.resetSelectedTasks())
-      }
     }
 
     componentDidUpdate(prevProps, prevState) {
       if (!_isEqual(_get(prevProps[tasksProp], 'tasks'), _get(this.props[tasksProp], 'tasks')) ||
           _get(prevProps[tasksProp], 'fetchId') !== _get(this.props[tasksProp], 'fetchId')) {
-        this.refreshSelectedTasks()
-      }
-
-      if (!_isEqual(this.state.selectedTasks, prevState.selectedTasks)) {
-        // If we have a listener for selected tasks let's pass up our changed tasks
-        if (this.props.setSelectedTasks) {
-          this.props.setSelectedTasks(this.state.selectedTasks)
-        }
+        this.refreshFilteredTasks()
       }
     }
 
@@ -439,27 +241,17 @@ export default function WithFilteredClusteredTasks(WrappedComponent,
         outputProp = tasksProp
       }
 
-      return <WrappedComponent {...{[outputProp]: this.state.filteredTasks}}
-                               includeTaskStatuses={this.state.includeStatuses}
-                               includeTaskReviewStatuses={this.state.includeReviewStatuses}
-                               includeTaskPriorities={this.state.includePriorities}
-                               selectedTasks={this.state.selectedTasks}
-                               toggleIncludedTaskStatus={this.toggleIncludedStatus}
-                               toggleIncludedTaskReviewStatus={this.toggleIncludedReviewStatus}
-                               toggleIncludedTaskPriority={this.toggleIncludedPriority}
-                               toggleTaskSelection={this.toggleTaskSelection}
-                               toggleTaskSelectionById={this.toggleTaskSelectionById}
-                               toggleAllTasksSelection={this.toggleAllTasksSelection}
-                               refreshSelectedTasks={this.refreshSelectedTasks}
-                               selectTasksWithStatus={this.selectTasksWithStatus}
-                               selectTasksWithPriority={this.selectTasksWithPriority}
-                               selectTasks={this.selectTasks}
-                               selectTasksById={this.selectTasksById}
-                               allTasksAreSelected={this.allTasksAreSelected}
-                               someTasksAreSelected={this.someTasksAreSelected}
-                               clearAllFilters={this.clearAllFilters}
-                               resetSelectedTasks={this.resetSelectedTasks}
-                               {..._omit(this.props, outputProp)} />
+      return <WrappedComponent
+        {...{[outputProp]: this.state.filteredTasks}}
+        includeTaskStatuses={this.state.includeStatuses}
+        includeTaskReviewStatuses={this.state.includeReviewStatuses}
+        includeTaskPriorities={this.state.includePriorities}
+        toggleIncludedTaskStatus={this.toggleIncludedStatus}
+        toggleIncludedTaskReviewStatus={this.toggleIncludedReviewStatus}
+        toggleIncludedTaskPriority={this.toggleIncludedPriority}
+        clearAllFilters={this.clearAllFilters}
+        {..._omit(this.props, outputProp)}
+      />
     }
   }
 }
diff --git a/src/components/HOCs/WithLayerSources/WithLayerSources.js b/src/components/HOCs/WithLayerSources/WithLayerSources.js
index 46cc1062..38388870 100644
--- a/src/components/HOCs/WithLayerSources/WithLayerSources.js
+++ b/src/components/HOCs/WithLayerSources/WithLayerSources.js
@@ -1,7 +1,5 @@
 import React, { Component } from 'react'
 import { injectIntl } from 'react-intl'
-import _clone from 'lodash/clone'
-import { LayerSources } from '../../../services/VisibleLayer/LayerSources'
 import AsMappableChallenge
        from '../../../interactions/Challenge/AsMappableChallenge'
 import AsMappingUser
@@ -11,7 +9,7 @@ import messages from './Messages'
 export default function WithLayerSources(WrappedComponent) {
   class _WithLayerSources extends Component {
     render() {
-      const allLayerSources = _clone(LayerSources)
+      const allLayerSources = AsMappingUser(this.props.user).allLayerSources()
 
       const challengeBasemapLayer =
         AsMappableChallenge(this.props.challenge).defaultLayerSource()
@@ -21,14 +19,6 @@ export default function WithLayerSources(WrappedComponent) {
         allLayerSources.push(challengeBasemapLayer)
       }
 
-      const userBasemapLayer =
-        AsMappingUser(this.props.user).defaultLayerSource()
-
-      if (userBasemapLayer && userBasemapLayer.isDynamic) {
-        userBasemapLayer.name = this.props.intl.formatMessage(messages.userDefault)
-        allLayerSources.push(userBasemapLayer)
-      }
-
       return <WrappedComponent layerSources={allLayerSources} {...this.props} />
     }
   }
diff --git a/src/components/HOCs/WithNearbyReviewTasks/WithNearbyReviewTasks.js b/src/components/HOCs/WithNearbyReviewTasks/WithNearbyReviewTasks.js
index 6fce563f..becc713b 100644
--- a/src/components/HOCs/WithNearbyReviewTasks/WithNearbyReviewTasks.js
+++ b/src/components/HOCs/WithNearbyReviewTasks/WithNearbyReviewTasks.js
@@ -17,6 +17,9 @@ export const WithNearbyReviewTasks = function(WrappedComponent) {
   class _WithNearbyReviewTasks extends Component {
     state = {
       nearbyTasks: null,
+      taskLimit: 5,
+      hasMoreToLoad: true,
+      lastLoadLength: 0,
     }
 
     /**
@@ -29,8 +32,11 @@ export const WithNearbyReviewTasks = function(WrappedComponent) {
      */
     updateNearbyReviewTasks = props => {
       this.setState({nearbyTasks: {loading: true}})
-      props.fetchNearbyReviewTasks(props.taskId, this.props.currentFilters).then(nearbyTasks => {
-        this.setState({nearbyTasks: {...nearbyTasks, nearTaskId: props.taskId, loading: false}})
+      props.fetchNearbyReviewTasks(props.taskId, this.props.currentFilters, this.state.taskLimit).then(nearbyTasks => {
+        const tasksLength = nearbyTasks.tasks.length
+        this.setState({nearbyTasks: {...nearbyTasks, nearTaskId: props.taskId, loading: false},
+                       lastLoadLength: tasksLength,
+                       hasMoreToLoad: (this.state.lastLoadLength !== tasksLength)})
       })
     }
 
@@ -38,7 +44,7 @@ export const WithNearbyReviewTasks = function(WrappedComponent) {
       this.updateNearbyReviewTasks(this.props)
     }
 
-    componentDidUpdate(prevProps) {
+    componentDidUpdate(prevProps, prevState) {
       if (this.state.nearbyTasks && !this.state.nearbyTasks.loading &&
           this.props.taskId !== this.state.nearbyTasks.nearTaskId) {
         return this.updateNearbyReviewTasks(this.props)
@@ -47,6 +53,10 @@ export const WithNearbyReviewTasks = function(WrappedComponent) {
       if (!_isEqual(this.props.currentFilters, prevProps.currentFilters)) {
         return this.updateNearbyReviewTasks(this.props)
       }
+
+      if (this.state.taskLimit !== prevState.taskLimit) {
+        return this.updateNearbyReviewTasks(this.props)
+      }
     }
 
     render() {
@@ -54,6 +64,8 @@ export const WithNearbyReviewTasks = function(WrappedComponent) {
         <WrappedComponent
           {..._omit(this.props, ['fetchNearbyReviewTasks'])}
           nearbyTasks={this.state.nearbyTasks}
+          increaseTaskLimit={() => this.setState({taskLimit: (this.state.taskLimit + 5)})}
+          hasMoreToLoad={this.state.hasMoreToLoad}
         />
       )
     }
diff --git a/src/components/HOCs/WithNearbyTasks/WithNearbyTasks.js b/src/components/HOCs/WithNearbyTasks/WithNearbyTasks.js
index 6c277400..34f5e628 100644
--- a/src/components/HOCs/WithNearbyTasks/WithNearbyTasks.js
+++ b/src/components/HOCs/WithNearbyTasks/WithNearbyTasks.js
@@ -18,6 +18,9 @@ export const WithNearbyTasks = function(WrappedComponent) {
   class _WithNearbyTasks extends Component {
     state = {
       nearbyTasks: null,
+      taskLimit: 5,
+      hasMoreToLoad: true,
+      lastLoadLength: 0,
     }
 
     /**
@@ -68,8 +71,11 @@ export const WithNearbyTasks = function(WrappedComponent) {
 
       if (_isFinite(challengeId)) {
         this.setState({nearbyTasks: {loading: true}})
-        props.fetchNearbyTasks(challengeId, isVirtual, props.taskId, excludeSelfLockedTasks).then(nearbyTasks => {
-          this.setState({nearbyTasks: {...nearbyTasks, nearTaskId: props.taskId, loading: false}})
+        props.fetchNearbyTasks(challengeId, isVirtual, props.taskId, excludeSelfLockedTasks, this.state.taskLimit).then(nearbyTasks => {
+          const tasksLength = nearbyTasks.tasks.length
+          this.setState({nearbyTasks: {...nearbyTasks, nearTaskId: props.taskId,
+                         loading: false}, lastLoadLength: tasksLength,
+                         hasMoreToLoad: (this.state.lastLoadLength !== tasksLength)})
         })
       }
     }
@@ -78,11 +84,14 @@ export const WithNearbyTasks = function(WrappedComponent) {
       this.updateNearbyTasks(this.props)
     }
 
-    componentDidUpdate() {
+    componentDidUpdate(prevProps, prevState) {
       if (this.state.nearbyTasks && !this.state.nearbyTasks.loading &&
           this.props.taskId !== this.state.nearbyTasks.nearTaskId) {
         this.updateNearbyTasks(this.props)
       }
+      else if (this.state.taskLimit !== prevState.taskLimit) {
+        this.updateNearbyTasks(this.props)
+      }
     }
 
     render() {
@@ -90,6 +99,8 @@ export const WithNearbyTasks = function(WrappedComponent) {
         <WrappedComponent
           {..._omit(this.props, ['fetchNearbyTasks'])}
           nearbyTasks={this.state.nearbyTasks}
+          increaseTaskLimit={() => this.setState({taskLimit: (this.state.taskLimit + 5)})}
+          hasMoreToLoad={this.state.hasMoreToLoad}
         />
       )
     }
@@ -107,4 +118,3 @@ export const mapDispatchToProps =
 
 export default WrappedComponent =>
   connect(null, mapDispatchToProps)(WithNearbyTasks(WrappedComponent))
-
diff --git a/src/components/HOCs/WithSelectedClusteredTasks/WithSelectedClusteredTasks.js b/src/components/HOCs/WithSelectedClusteredTasks/WithSelectedClusteredTasks.js
new file mode 100644
index 00000000..503ce405
--- /dev/null
+++ b/src/components/HOCs/WithSelectedClusteredTasks/WithSelectedClusteredTasks.js
@@ -0,0 +1,225 @@
+import React, { Component } from 'react'
+import _isEmpty from 'lodash/isEmpty'
+import _each from 'lodash/each'
+
+/**
+ * WithSelectedClusteredTasks handles management of selection of individual
+ * tasks, including an option for selecting all tasks even if not all tasks are
+ * locally available (with subsequent deselection of individual tasks)
+ *
+ * @author [Neil Rotstan](https://github.com/nrotstan)
+ */
+export default function WithSelectedClusteredTasks(WrappedComponent) {
+  return class extends Component {
+    state = {
+      allSelected: false,         // has user toggled selection of all tasks
+      selectedTasks: new Map(),   // used when allSelected flag is false
+      deselectedTasks: new Map(), // used when allSelected flag is true
+    }
+
+    /**
+     * Toggle selection of the given task on or off
+     */
+    toggleTaskSelection = task => {
+      // If allSelected is false, then we work off the selectedTasks map;
+      // otherwise we work off the deselectedTasks map
+      if (!this.state.allSelected) {
+        const selected = new Map(this.state.selectedTasks)
+        selected.has(task.id) ? selected.delete(task.id) : selected.set(task.id, task)
+        this.setState({selectedTasks: selected})
+      }
+      else {
+        const deselected = new Map(this.state.deselectedTasks)
+        deselected.has(task.id) ? deselected.delete(task.id) : deselected.set(task.id, task)
+        this.setState({deselectedTasks: deselected})
+      }
+    }
+
+    /**
+     * Select multiple tasks, matching the given task ids, from the given array
+     * of tasks (or single-task clusters)
+     */
+    selectTasks = tasks => {
+      if (_isEmpty(tasks) ||
+          (this.state.allSelected && this.state.deselectedTasks.size === 0)) {
+        // Nothing to do
+        return
+      }
+
+      // If allSelected is false, we need to add to the selectedTasks map;
+      // otherwise we need to remove from the deselectedTasks map
+      if (!this.state.allSelected) {
+        const selectedTasks = new Map(this.state.selectedTasks)
+        _each(tasks, task => selectedTasks.set(task.id, task))
+        this.setState({selectedTasks})
+      }
+      else {
+        const deselectedTasks = new Map(this.state.deselectedTasks)
+        _each(tasks, task => {
+          if (deselectedTasks.has(task.id)) {
+            deselectedTasks.delete(task.id)
+          }
+        })
+        this.setState({deselectedTasks})
+      }
+    }
+
+    /**
+     * Deselect multiple tasks, matching the given task ids, from the given
+     * array of tasks (or single-task clusters)
+     */
+    deselectTasks = tasks => {
+      if (_isEmpty(tasks) ||
+          (!this.state.allSelected && this.state.selectedTasks.size === 0)) {
+        // Nothing to do
+        return
+      }
+
+      // If allSelected is true, we need to add to the deselectedTasks map;
+      // otherwise we need to remove from the selectedTasks map
+      if (this.state.allSelected) {
+        const deselectedTasks = new Map(this.state.deselectedTasks)
+        _each(tasks, task => deselectedTasks.set(task.id, task))
+        this.setState({deselectedTasks})
+      }
+      else {
+        const selectedTasks = new Map(this.state.selectedTasks)
+        _each(tasks, task => {
+          if (selectedTasks.has(task.id)) {
+            selectedTasks.delete(task.id)
+          }
+        })
+        this.setState({selectedTasks})
+      }
+    }
+
+    /**
+     * Returns true if all tasks are actually selected, false if not. If
+     * allSelected is true, that means the deselectedTasks map is empty; if
+     * allSelected is false, then that means the selectedTasks map size equals
+     * the number of total available tasks
+     */
+    allTasksAreSelected = totalTasksAvailable => {
+      if (this.state.allSelected) {
+        return this.state.deselectedTasks.size === 0
+      }
+
+      return totalTasksAvailable > 0 &&
+             this.state.selectedTasks.size === totalTasksAvailable
+    }
+
+    /**
+     * Returns true if the given taskId is actively selected, false if not
+     */
+    isTaskSelected = taskId => {
+      return this.state.selectedTasks.has(taskId) ||
+             (this.state.allSelected && !this.state.deselectedTasks.has(taskId))
+    }
+
+    /**
+     * Returns true if some, but not all, tasks are selected
+     */
+    someTasksAreSelected = totalTasksAvailable => {
+      if (this.state.allSelected) {
+        return this.state.deselectedTasks.size > 0
+      }
+
+      return this.state.selectedTasks.size > 0 && !this.allTasksAreSelected(totalTasksAvailable)
+    }
+
+    /**
+     * Returns the total number of selected tasks
+     */
+    selectedTaskCount = totalTasksAvailable => {
+      if (this.state.allSelected) {
+        return totalTasksAvailable - this.state.deselectedTasks.size
+      }
+
+      return this.state.selectedTasks.size
+    }
+
+    /**
+     * Toggle selection of all the tasks on or off. If not all tasks are
+     * currently selected, then all will be selected; if all were selected then
+     * all will be unselected.
+     */
+    toggleAllTasksSelection = totalTasksAvailable => {
+      this.setState({
+        selectedTasks: new Map(),
+        deselectedTasks: new Map(),
+        allSelected: !this.allTasksAreSelected(totalTasksAvailable),
+      })
+    }
+
+    /**
+     * Reset task selections, unselecting all tasks
+     */
+    resetSelectedTasks = () => {
+      this.setState({
+        selectedTasks: new Map(),
+        deselectedTasks: new Map(),
+        allSelected: false,
+      })
+    }
+
+    /**
+     * Prune tasks that no longer needed to be tracked from the selectedTasks
+     * and deselectedTasks maps. The given filter function will be called with
+     * each task and, if it returns truthy, the task will be pruned
+     */
+    pruneSelectedTasks = filterFunction => {
+      this.trimMap("selectedTasks", filterFunction)
+      this.trimMap("deselectedTasks", filterFunction)
+    }
+
+    /**
+     * Trims from a named map (present in state) any tasks that pass the given
+     * filter function
+     *
+     * @private
+     */
+    trimMap = (mapName, filterFunction) => {
+      let trimmedMap = null
+      for (let [taskId, task] of this.state[mapName]) {
+        if (filterFunction(task)) {
+          if (!trimmedMap) {
+            trimmedMap = new Map(this.state[mapName])
+          }
+          trimmedMap.delete(taskId)
+        }
+      }
+
+      if (trimmedMap) {
+        this.setState({[mapName]: trimmedMap})
+      }
+    }
+
+    componentDidMount() {
+      // Provide parent with way to reset task selections, if desired
+      if (this.props.setResetSelectedTasksAccessor) {
+        this.props.setResetSelectedTasksAccessor(() => this.resetSelectedTasks())
+      }
+    }
+
+    render() {
+      return <WrappedComponent
+        {...this.props}
+        selectedTasks={Object.freeze({
+          allSelected: this.state.allSelected,
+          selected: this.state.selectedTasks,
+          deselected: this.state.deselectedTasks
+        })}
+        selectTasks={this.selectTasks}
+        deselectTasks={this.deselectTasks}
+        toggleTaskSelection={this.toggleTaskSelection}
+        toggleAllTasksSelection={this.toggleAllTasksSelection}
+        allTasksAreSelected={this.allTasksAreSelected}
+        someTasksAreSelected={this.someTasksAreSelected}
+        selectedTaskCount={this.selectedTaskCount}
+        isTaskSelected={this.isTaskSelected}
+        resetSelectedTasks={this.resetSelectedTasks}
+        pruneSelectedTasks={this.pruneSelectedTasks}
+      />
+    }
+  }
+}
diff --git a/src/components/HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers.js b/src/components/HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers.js
index 6eea389e..2bb88ea8 100644
--- a/src/components/HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers.js
+++ b/src/components/HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers.js
@@ -22,7 +22,7 @@ export const WithTaskClusterMarkers = function(WrappedComponent) {
 
     componentDidUpdate(prevProps) {
       if (!_isEqual(this.props.taskClusters, prevProps.taskClusters) ||
-          !_isEqual(this.props.chosenTasks, prevProps.chosenTasks)) {
+          !_isEqual(this.props.selectedTasks, prevProps.selectedTasks)) {
         this.updateMapMarkers()
       }
     }
@@ -34,8 +34,8 @@ export const WithTaskClusterMarkers = function(WrappedComponent) {
       const markers = _map(this.props.taskClusters, cluster => {
         return AsMappableCluster(cluster).mapMarker(
           this.props.monochromaticClusters,
-          this.props.chosenTasks,
-          this.props.highlightPrimaryTask
+          this.props.selectedTasks,
+          this.props.highlightPrimaryTask,
         )
       })
 
diff --git a/src/components/HOCs/WithTaskReview/WithTaskReview.js b/src/components/HOCs/WithTaskReview/WithTaskReview.js
index 8174541c..068679ae 100644
--- a/src/components/HOCs/WithTaskReview/WithTaskReview.js
+++ b/src/components/HOCs/WithTaskReview/WithTaskReview.js
@@ -25,10 +25,10 @@ const WithTaskReview = WrappedComponent =>
 const mapDispatchToProps = (dispatch, ownProps) => {
   return {
     updateTaskReviewStatus: (task, status, comment, tags, loadBy, url,
-      taskBundle, requestedNextTask) => {
+      taskBundle, requestedNextTask, newTaskStatus) => {
         const doReview = taskBundle ?
-          completeBundleReview(taskBundle.bundleId, status, comment, tags) :
-          completeReview(task.id, status, comment, tags)
+          completeBundleReview(taskBundle.bundleId, status, comment, tags, newTaskStatus) :
+          completeReview(task.id, status, comment, tags, newTaskStatus)
 
         dispatch(doReview).then(() => {
           if (loadBy === TaskReviewLoadMethod.nearby && requestedNextTask) {
diff --git a/src/components/HOCs/WithVisibleLayer/WithVisibleLayer.js b/src/components/HOCs/WithVisibleLayer/WithVisibleLayer.js
index 52bc9086..9ebf4727 100644
--- a/src/components/HOCs/WithVisibleLayer/WithVisibleLayer.js
+++ b/src/components/HOCs/WithVisibleLayer/WithVisibleLayer.js
@@ -67,7 +67,7 @@ export const dynamicLayerWithId = (layerId, ownProps) => {
     return challengeLayer
   }
 
-  const userLayer = AsMappingUser(ownProps.user).defaultLayerSource()
+  const userLayer = AsMappingUser(ownProps.user).findLayerSource(layerId)
   if (userLayer && userLayer.id === layerId) {
     return userLayer
   }
@@ -82,7 +82,6 @@ export const mapStateToProps = (state, ownProps) => {
   if (_isFinite(challengeId)) {
     if (_isString(ownProps.visibleMapLayer)) {
       source = layerSourceWithId(ownProps.visibleMapLayer)
-
       if (!source) {
         // Try a dynamic layer
         source = dynamicLayerWithId(ownProps.visibleMapLayer, ownProps)
diff --git a/src/components/MarkdownContent/MarkdownTemplate.js b/src/components/MarkdownContent/MarkdownTemplate.js
index 758e9690..13cd8853 100644
--- a/src/components/MarkdownContent/MarkdownTemplate.js
+++ b/src/components/MarkdownContent/MarkdownTemplate.js
@@ -13,15 +13,18 @@ import Handlebars from 'handlebars'
 import { renderToStaticMarkup } from 'react-dom/server'
 
 /**
- * MarkdownTemplate will handle converting markdown content to html.
- * It was also handle basic mustache tag replacement (eg. {{propName}}).
- * It also handles template tags which are removed from the text and
- * converted into input fields that are outputed in a list following the
- * normal textual content.
+ * MarkdownTemplate will handle converting markdown content to html, as well as
+ * basic mustache tag replacement for task properties (eg. {{propName}}).
+ * Handlebars is used in combination with custom handlers to convert input
+ * fields that are outputed in a basic form following the normal textual
+ * content.
  *
- * Current supported template tags are as follows:
- * {{checkbox "Label for checkbox" name="propertyName"}}
- * {{select "Label for select" name="propertyName" values="value1,value2,etc."}}
+ * Current supported template/form tags are as follows (note that they use
+ * three mustache tags instead of the usual two used for property
+ * substitution):
+ *
+ * {{{checkbox "Label for checkbox" name="propertyName"}}}
+ * {{{select "Label for select" name="propertyName" values="value1,value2,etc."}}}
  *
  * @author [Kelli Rotstan](https://github.com/lrotstan)
  */
@@ -106,14 +109,17 @@ export default class MarkdownTemplate extends Component {
   }
 
   substitutePropertyTags = (text, properties) => {
-    // Handlebars does not handle @ properties (eg. @id). So we will do a
-    // simple substitution first.
+    // Handlebars does not handle @ properties (eg. @id), so we handle property
+    // substitution ourselves. We also run each property through linkify to turn
+    // URLs into links since property substitution happens after Markdown parsing,
+    // so that wouldn't otherwise happen
     let substituted = _cloneDeep(text)
     _keys(properties).forEach(key => {
       let safe = properties[key]
       if (safe) {
         safe = safe.toString().replace(/</g, '&lt;')
         safe = safe.toString().replace(/>/g, '&gt;')
+        safe = this.linkify(safe)
       }
 
       substituted = substituted.replace(RegExp(`{{\\s*${key}\\s*}}`, "g"), safe)
@@ -192,6 +198,14 @@ export default class MarkdownTemplate extends Component {
     )
   }
 
+  linkify(text) {
+    const urlPattern = /(?:https?:)?\/\/(?:(?:[\w-]+\.)+[\w/#@~.-]*)(?:\?(?:[\w&=.!,;$#%-]+)?)?/gi
+
+    return (text || '').replace(urlPattern, url =>
+      '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + url + '</a>'
+    )
+  }
+
   render() {
     if (_isEmpty(this.state.content)) {
       return null
diff --git a/src/components/Sprites/Sprites.js b/src/components/Sprites/Sprites.js
index 79949c67..81576630 100644
--- a/src/components/Sprites/Sprites.js
+++ b/src/components/Sprites/Sprites.js
@@ -861,6 +861,12 @@ export default function() {
       C175.299,88.872,242.799,70.422,303.2,70.422c112.008,0,168.8,30.016,168.8,89.213C472,207.471,419.006,241.523,387.417,257.563z"
       />
         </symbol>
+        <symbol id="lasso-add-icon" viewBox="0 0 512 512">
+          <path fill="none" d="M-1-1h802v602H-1z"/><g><path d="M443.938 55.155c-34.079-16.412-81.43-24.733-140.737-24.733-67.208 0-142.276 20.507-200.808 54.855-30.429 17.857-54.75 38.254-72.289 60.625C10.128 171.382 0 198.117 0 225.365c0 34.792 20.897 65.455 57.886 85.851A60.038 60.038 0 0057 321.422c0 33.084 26.916 60 60 60 7.527 0 14.766-1.134 21.63-3.044 3.444 4.59 7.37 13.093 7.37 28.044 0 25.437-35.48 36.76-35.747 36.843l11.494 38.313c1.602-.481 16.076-4.998 30.831-15.474C174.443 450.58 186 429.943 186 406.422c0-19.833-4.884-34.967-11.464-46.071 9.843-7.178 17.546-14.704 22.438-19.963 1.927.02 3.86.034 5.8.034 68.761 0 144.557-17.642 202.753-47.193C474.188 258.365 512 210.92 512 159.635c0-46.908-23.535-83.037-68.062-104.48zM117 341.422c-11.028 0-20-8.973-20-20 0-11.028 8.972-20 20-20 15.314 0 31.084 10.385 42.075 19.992-10.952 9.544-26.795 20.008-42.075 20.008zm270.417-83.859c-52.851 26.837-121.876 42.859-184.643 42.859-2.61 0-5.21-.021-7.786-.061-13.414-13.891-42.521-38.939-77.989-38.939-15.142 0-28.981 5.65-39.55 14.937C53.199 263.158 40 245.328 40 225.365c0-36.285 30.12-74.771 82.638-105.59C175.299 88.872 242.799 70.422 303.2 70.422c112.008 0 168.8 30.016 168.8 89.213 0 47.836-52.994 81.888-84.583 97.928z"/><path strokeWidth="30" d="M401 329.453l-1 165M483 412.453l-165-1"/></g>
+        </symbol>
+        <symbol id="lasso-remove-icon" viewBox="0 0 512 512">
+          <path fill="none" d="M-1-1h802v602H-1z"/><g><path d="M443.938 55.155c-34.079-16.412-81.43-24.733-140.737-24.733-67.208 0-142.276 20.507-200.808 54.855-30.429 17.857-54.75 38.254-72.289 60.625C10.128 171.382 0 198.117 0 225.365c0 34.792 20.897 65.455 57.886 85.851A60.038 60.038 0 0057 321.422c0 33.084 26.916 60 60 60 7.527 0 14.766-1.134 21.63-3.044 3.444 4.59 7.37 13.093 7.37 28.044 0 25.437-35.48 36.76-35.747 36.843l11.494 38.313c1.602-.481 16.076-4.998 30.831-15.474C174.443 450.58 186 429.943 186 406.422c0-19.833-4.884-34.967-11.464-46.071 9.843-7.178 17.546-14.704 22.438-19.963 1.927.02 3.86.034 5.8.034 68.761 0 144.557-17.642 202.753-47.193C474.188 258.365 512 210.92 512 159.635c0-46.908-23.535-83.037-68.062-104.48zM117 341.422c-11.028 0-20-8.973-20-20 0-11.028 8.972-20 20-20 15.314 0 31.084 10.385 42.075 19.992-10.952 9.544-26.795 20.008-42.075 20.008zm270.417-83.859c-52.851 26.837-121.876 42.859-184.643 42.859-2.61 0-5.21-.021-7.786-.061-13.414-13.891-42.521-38.939-77.989-38.939-15.142 0-28.981 5.65-39.55 14.937C53.199 263.158 40 245.328 40 225.365c0-36.285 30.12-74.771 82.638-105.59C175.299 88.872 242.799 70.422 303.2 70.422c112.008 0 168.8 30.016 168.8 89.213 0 47.836-52.994 81.888-84.583 97.928z"/><path strokeWidth="30" d="M483 412.453l-165-1"/></g>
+        </symbol>
         <symbol id="datasource-icon" viewBox="0 0 106 122">
           <path d="M105.209 87.308l-.02.147.02-.147zM8.066 13.352zm-2.245 1.27c.35-.256.706-.5 1.08-.718a8.32 8.32 0 01.875-.427c.402-.166.808-.318 1.208-.485a43.901 43.901 0 001.596-.702c.892-.414 1.772-.854 2.655-1.286a126.02 126.02 0 015.43-2.492c.475-.194.953-.38 1.431-.567.492-.193.977-.403 1.462-.612.997-.399 1.987-.819 3.022-1.11.595-.167 1.2-.29 1.804-.42a50.94 50.94 0 011.73-.345c1.228-.223 2.456-.447 3.68-.694 1.239-.25 2.478-.508 3.727-.712l1.327-.217a65.168 65.168 0 012.023-.231c.78-.072 1.554-.176 2.33-.285 1.137-.144 2.273-.31 3.415-.408a29.871 29.871 0 013.697-.104c1.214.048 2.424.143 3.639.165 1.184.022 2.367.075 3.547.174.675.056 1.35.09 2.024.137.642.045 1.278.122 1.916.197 1.314.154 2.624.319 3.936.484.89.131 1.777.268 2.662.416.817.137 1.63.297 2.45.427l1.57.248c.583.093 1.174.15 1.76.224.77.096 1.542.188 2.312.286 2.435.4 4.845.971 7.232 1.596.663.174 1.33.34 1.992.522.619.17 1.247.324 1.86.513.65.2 1.296.4 1.938.617.619.209 1.234.425 1.851.639.357.124.707.272 1.058.413 1.477.68 2.959 1.355 4.404 2.098.741.381 1.474.777 2.21 1.17.622.331 1.253.66 1.904.936.105.348.166.707.23 1.067.09.498.2.992.28 1.491.128 1.132.164 2.275.232 3.41.035.585.03 1.172.045 1.756.016.673.038 1.345.069 2.018.03.668.097 1.335.186 1.998.082.6.195 1.192.303 1.786.222 1.21.461 2.417.652 3.631.024.148.046.295.067.442.143 1.206.26 2.415.369 3.624.11 1.216.242 2.429.324 3.647.043.647.1 1.292.161 1.937.06.614.1 1.23.157 1.843.113 1.196.25 2.39.378 3.584.129 1.182.27 2.362.36 3.547.044.57.097 1.139.133 1.709.04.644.07 1.288.104 1.932.068 1.288.076 2.579.122 3.869.041 1.175.166 2.342.224 3.515.032.646.068 1.291.094 1.937.027.625.014 1.253.014 1.878v3.709c0 2.506.003 5.013-.006 7.52-.008 1.847-.016 3.694-.096 5.54-.04.923-.134 1.842-.18 2.764a36.844 36.844 0 01-.222 2.725l.015-.11c-.05.367-.096.736-.144 1.105-.177 1.181-.397 2.356-.613 3.53-.215 1.17-.412 2.344-.62 3.516-.21 1.172-.449 2.338-.652 3.51-.124.713-.26 1.428-.335 2.147a43.634 43.634 0 00-.145 1.806c-.034.547-.053 1.094-.087 1.641a43.653 43.653 0 00-.08 1.72c-.023 1.142-.022 2.291-.137 3.428-.03.163-.065.323-.116.48a3.39 3.39 0 01-.163.303 3.673 3.673 0 01-.248.25c-.324.228-.668.422-1.003.637a7.528 7.528 0 01-1.102.56c-.525.214-1.061.4-1.593.596-.489.18-.97.376-1.458.557-.557.208-1.123.385-1.696.544-.605.166-1.216.309-1.823.466-.543.14-1.073.355-1.6.544-.519.186-1.038.37-1.565.532-1.176.36-2.371.655-3.568.94a51.737 51.737 0 01-3.117.62c-.475.065-.95.133-1.427.179-.65.062-1.3.124-1.95.183-1.3.119-2.598.26-3.893.435-1.273.171-2.545.356-3.822.493-1.246.135-2.493.254-3.74.388-2.69.288-5.371.621-8.054.959-.594.075-1.19.129-1.786.185l-.927.088c-.194.018-.39.018-.586.017l-.284.001c-.618.013-1.232.026-1.85.01-.646-.015-1.292-.028-1.938-.056-1.352-.06-2.704-.101-4.056-.15-1.268-.048-2.534-.136-3.801-.193a58.44 58.44 0 01-3.786-.29c-1.173-.13-2.343-.28-3.514-.433-1.157-.165-2.304-.384-3.454-.588-1.185-.21-2.371-.42-3.551-.658a50.123 50.123 0 01-3.516-.87 38.311 38.311 0 01-1.643-.507c-.57-.188-1.125-.423-1.69-.629-.753-.274-1.506-.543-2.253-.836a63.294 63.294 0 01-1.26-.572c-.46-.213-.923-.416-1.377-.64-.477-.237-.962-.456-1.439-.692a21.05 21.05 0 01-1.41-.782 59.897 59.897 0 00-3.158-1.789 25.728 25.728 0 00-1.846-.893c-.526-.222-1.062-.414-1.59-.63a17.594 17.594 0 01-.582-.268c-.257-.124-.496-.286-.738-.438a21.451 21.451 0 01-1.492-1.057 6.613 6.613 0 01-.757-.677 2.783 2.783 0 01-.034-.062 2.495 2.495 0 01-.053-.216c-.046-.522-.088-1.044-.134-1.565-.056-.633-.076-1.266-.12-1.899-.04-.604-.111-1.205-.155-1.81-.045-.616-.068-1.235-.104-1.851-.034-.602-.03-1.205-.03-1.808V90.8c0-4.949-.006-9.898.002-14.846.003-2.482 0-4.966.101-7.446.024-.609.081-1.216.095-1.824.013-.591.01-1.183.012-1.774.002-1.28.012-2.558.039-3.836.027-1.26.093-2.518.143-3.777.053-1.313.026-2.63.026-3.945 0-2.526-.004-5.052.036-7.576a81.9 81.9 0 01.154-3.74c.037-.58.023-1.163.026-1.743.003-.639.007-1.278.014-1.916.012-1.17.064-2.336.14-3.503a40.03 40.03 0 00.081-1.733c.012-.571.035-1.143.071-1.713.038-.602.095-1.203.125-1.805.03-.613.045-1.227.07-1.841.024-.613.069-1.225.095-1.837.025-.578.023-1.158.023-1.736 0-2.538-.013-5.077.062-7.615l.02-.619a8.308 8.308 0 01.64-.59c.311-.248.61-.51.918-.763zM44.616.213c-1.189.102-2.372.277-3.556.426-.245.031-.488.067-.732.101-.56.073-1.12.135-1.682.187-.651.061-1.3.147-1.95.229-1.362.17-2.714.44-4.064.695-1.202.229-2.4.482-3.605.708-.645.122-1.292.237-1.938.355-.569.103-1.132.229-1.698.347a22.98 22.98 0 00-1.524.361 17.71 17.71 0 00-1.41.465c-.875.326-1.737.694-2.595 1.063-.389.159-.78.312-1.174.457-.59.219-1.168.486-1.747.734-.95.408-1.898.821-2.83 1.266a358.5 358.5 0 00-2.932 1.411c-.89.435-1.781.872-2.684 1.28-.905.37-1.818.714-2.696 1.145-.474.234-.92.54-1.343.857-.247.187-.483.385-.72.584a1.338 1.338 0 00-.75-.238c-.75 0-1.317.614-1.34 1.349-.047 1.44-.099 2.882-.123 4.323-.02 1.27-.02 2.539-.02 3.808-.002 1.19.015 2.382-.014 3.573-.015.635-.068 1.27-.1 1.905-.029.58-.033 1.16-.062 1.74-.06 1.213-.173 2.421-.206 3.635a43.534 43.534 0 01-.075 1.747 79.718 79.718 0 00-.1 1.737C.893 37.64.89 38.816.883 39.993c-.003.635.007 1.272-.02 1.906-.024.575-.074 1.148-.095 1.724-.096 2.55-.097 5.102-.1 7.654-.002 1.28-.002 2.562-.002 3.842 0 .625.012 1.252-.013 1.876-.027.646-.061 1.292-.089 1.938A136.905 136.905 0 00.47 62.78c-.018 1.203.01 2.408-.016 3.611C.426 67.61.326 68.828.3 70.05c-.028 1.319-.038 2.64-.045 3.96-.023 4.914-.007 9.83-.007 14.745v7.481c0 .583-.003 1.167.022 1.75.027.599.07 1.197.1 1.797.031.598.102 1.192.149 1.79.051.647.067 1.297.121 1.944.036.431.076.862.112 1.294.016.195.03.39.049.586.016.175.044.35.069.524.038.27.14.521.253.766.25.539.664.925 1.107 1.298.413.348.846.673 1.284.99.62.452 1.254.892 1.95 1.216.77.359 1.569.64 2.353.963 1.082.48 2.106 1.093 3.143 1.662.973.534 1.898 1.15 2.893 1.644.917.455 1.848.882 2.776 1.312.596.276 1.193.562 1.803.802.582.23 1.166.448 1.754.66.58.212 1.156.427 1.733.646.54.206 1.094.374 1.647.54 1.16.347 2.33.67 3.517.913 1.179.242 2.362.466 3.548.672.578.1 1.155.213 1.733.317.578.104 1.158.217 1.74.297 1.397.192 2.795.377 4.196.539a70.26 70.26 0 001.78.186c.677.06 1.354.1 2.034.14.566.033 1.134.049 1.701.08.708.041 1.417.08 2.125.117 1.178.062 2.359.072 3.537.125.645.028 1.29.06 1.935.076.634.015 1.27.048 1.904.034l1.27-.027c.216-.005.433-.002.65-.022l2.098-.198c.702-.067 1.402-.177 2.103-.265.64-.08 1.281-.163 1.922-.244 1.234-.157 2.47-.293 3.706-.425 1.194-.128 2.39-.242 3.583-.37.637-.069 1.276-.13 1.911-.213.648-.087 1.295-.171 1.943-.259 1.29-.173 2.582-.357 3.879-.475 1.355-.123 2.708-.239 4.058-.414a37.992 37.992 0 003.912-.703c1.228-.293 2.456-.593 3.666-.957 1.08-.325 2.123-.759 3.201-1.088.574-.176 1.17-.286 1.748-.447a20.335 20.335 0 001.66-.515c.51-.19 1.012-.403 1.525-.585.56-.197 1.125-.392 1.672-.621.6-.252 1.23-.492 1.78-.847.231-.15.466-.291.703-.43.3-.177.595-.412.855-.638.099-.084.186-.186.273-.282.179-.198.35-.418.469-.658.105-.21.199-.419.287-.637.1-.245.151-.494.193-.754.105-.661.142-1.332.164-2 .022-.676.036-1.352.05-2.028.013-.611.05-1.22.087-1.83.066-1.085.108-2.169.216-3.252.149-1.013.331-2.024.522-3.03.212-1.117.419-2.233.615-3.353.195-1.111.385-2.223.596-3.332.1-.52.183-1.044.272-1.566.107-.623.187-1.248.268-1.873l-.012.088.013-.09.007-.058c.085-.627.168-1.253.214-1.883.047-.629.074-1.259.114-1.888.04-.622.093-1.243.129-1.865.035-.6.055-1.202.07-1.803.065-2.482.05-4.966.053-7.45.002-2.502 0-5.007 0-7.511 0-.619.009-1.238-.003-1.857-.013-.649-.05-1.297-.082-1.945-.062-1.183-.177-2.362-.229-3.546-.055-1.254-.059-2.509-.112-3.763a101.677 101.677 0 00-.578-7.29c-.067-.588-.138-1.176-.202-1.763-.066-.617-.118-1.236-.184-1.853a63.013 63.013 0 01-.156-1.778c-.05-.646-.12-1.29-.17-1.935-.046-.593-.079-1.187-.132-1.779-.061-.675-.132-1.35-.196-2.024-.115-1.215-.216-2.431-.362-3.642l-.04-.315a22.09 22.09 0 01-.23-1.05c-.155-.812-.25-1.636-.348-2.456a24.824 24.824 0 00-.02-.112c-.155-.882-.342-1.76-.473-2.646a42.35 42.35 0 01-.195-3.388c-.02-.276-.028-.553-.051-.828-.03-.357-.062-.712-.03-1.069l.021-.216c-.023-.42-.051-.838-.075-1.257-.03-.55-.059-1.1-.111-1.65a18.584 18.584 0 00-.31-2c-.103-.502-.175-1.009-.297-1.508-.026-.11-.069-.217-.107-.323-.125-.34-.263-.65-.505-.921a1.879 1.879 0 00-.654-.469l-.346-.144c-1.006-.47-1.98-1.013-2.957-1.541-1.09-.59-2.218-1.125-3.342-1.647-1.25-.58-2.494-1.178-3.798-1.631-1.207-.42-2.414-.862-3.648-1.201a317.67 317.67 0 00-3.906-1.048c-1.262-.33-2.529-.627-3.8-.913-1.252-.281-2.506-.549-3.78-.714-1.228-.16-2.458-.306-3.687-.458-.547-.086-1.096-.164-1.64-.26-.618-.11-1.234-.225-1.852-.328a105.331 105.331 0 00-4.278-.644c-1.25-.158-2.5-.311-3.752-.459A37.98 37.98 0 0057.34.572C56.725.53 56.112.5 55.499.448a54.763 54.763 0 00-3.547-.174C50.737.252 49.527.157 48.313.11a27.24 27.24 0 00-.99-.018c-.903 0-1.806.045-2.707.122z" />
           <path d="M102.765 28.33l-.023-.18v-.004l.023.185m2.408 59.246v-.002.002m-4.965-63.847l-.077.1.077-.1zM2.67 15.275c.048 1.058.295 2.143.763 3.096.245.5.597.925.935 1.364.312.404.678.755 1.053 1.098.977.895 2.009 1.721 3.056 2.53.88.733 1.747 1.48 2.653 2.182a38.112 38.112 0 002.57 1.805c.873.57 1.748 1.142 2.645 1.673.428.254.874.475 1.317.701.493.252.988.498 1.485.74 1.014.496 2.074.893 3.119 1.319.951.388 1.893.8 2.87 1.119.461.15.927.295 1.397.41.492.12.989.212 1.486.303 1.03.19 2.047.425 3.068.654 1.007.224 2.013.458 3.023.665 1.056.217 2.121.375 3.183.56.984.17 1.97.316 2.956.481 1.08.181 2.168.288 3.256.406 1.01.11 2.019.17 3.034.186 1.013.018 2.028-.075 3.04-.103 1.019-.03 2.033-.1 3.05-.161.517-.031 1.035-.047 1.552-.079.539-.034 1.077-.088 1.615-.124 1.022-.07 2.047-.081 3.07-.101 1.045-.02 2.09-.151 3.127-.27 1.152-.131 2.3-.322 3.431-.579 1-.227 1.993-.47 2.989-.711 2.183-.528 4.364-1.061 6.56-1.535.99-.214 1.993-.368 2.987-.56 1.134-.222 2.269-.44 3.401-.67 2.151-.438 4.295-.904 6.433-1.404 2.135-.501 4.261-1.049 6.386-1.595 1.387-.357 2.777-.706 4.162-1.07.327-.085.654-.17.977-.268.41-.125.804-.296 1.196-.468.412-.182.799-.476 1.128-.777.266-.243.494-.51.715-.79-.023-.22-.043-.441-.065-.661-.042-.401-.054-.803-.087-1.205-.033-.39-.084-.776-.13-1.164-.03-.247-.041-.497-.055-.746-.144.172-.29.344-.435.514-.217.256-.445.501-.656.764-.235.293-.465.59-.694.887l.103-.134a6.05 6.05 0 01-.64.666 4.202 4.202 0 01-.56.324c-.823.318-1.694.503-2.547.721-1.021.261-2.041.527-3.062.79-2.118.544-4.237 1.092-6.366 1.59-2.14.502-4.287.97-6.44 1.41-2.147.436-4.303.829-6.455 1.243-.997.192-1.986.44-2.974.673-1.053.248-2.105.503-3.156.757-1.003.242-2.004.489-3.008.723-.96.224-1.921.399-2.894.547-.948.118-1.903.233-2.857.278-1.044.049-2.09.044-3.133.097-1.05.053-2.095.159-3.146.205-1.012.045-2.02.14-3.033.17-1.413.042-2.823.143-4.236.102a29.226 29.226 0 01-2.061-.14c-.674-.068-1.349-.137-2.021-.214-.883-.128-1.762-.289-2.644-.423-.509-.077-1.014-.174-1.522-.256a107.45 107.45 0 01-1.616-.269c-2.062-.361-4.094-.885-6.14-1.328-.77-.166-1.548-.283-2.318-.449-.73-.157-1.452-.394-2.157-.641-.753-.266-1.486-.589-2.225-.89-.67-.274-1.342-.542-2.01-.82a74.1 74.1 0 01-2.351-1.155c-.36-.184-.72-.368-1.07-.574-.402-.239-.796-.49-1.191-.74a81.51 81.51 0 01-2.143-1.389 39.08 39.08 0 01-1.87-1.343c-.949-.765-1.859-1.577-2.83-2.314a39.118 39.118 0 01-1.608-1.32c-.285-.25-.57-.501-.845-.761a12.952 12.952 0 01-.685-.68c-.198-.216-.37-.458-.548-.69l.188.244a4.646 4.646 0 01-.435-.735 8.169 8.169 0 01-.356-1.34 7.052 7.052 0 01-.024-.35c-.016-.364-.133-.693-.393-.954a1.34 1.34 0 00-.947-.395c-.706 0-1.373.613-1.34 1.348zm99.665 10.059l.024-.031-.024.03M1.993 44.597c-.24.242-.376.573-.376.914 0 .307.123.716.376.914.9.707 1.797 1.418 2.704 2.116.382.294.766.586 1.152.875.372.279.785.504 1.198.715.794.404 1.588.817 2.395 1.194.814.381 1.64.728 2.467 1.078.25.11.501.217.752.323.91.407 1.798.864 2.69 1.311.914.459 1.821.928 2.734 1.39.918.465 1.862.862 2.803 1.277.296.13.596.274.899.384l.865.313c.263.095.538.162.805.243.217.065.433.132.65.192 1.086.299 2.173.589 3.27.846.536.126 1.073.261 1.617.354.532.09 1.065.17 1.598.254.592.093 1.185.18 1.782.235.54.05 1.082.096 1.623.15 1.083.11 2.161.252 3.24.39.853.11 1.708.207 2.56.319.932.132 1.857.293 2.787.427a35.86 35.86 0 001.9.242 33.87 33.87 0 001.713.111c2.032.087 4.07-.01 6.1.13.527.038 1.054.071 1.582.09.526.02 1.055.044 1.581.017.473-.025.944-.061 1.416-.086.538-.027 1.076-.035 1.614-.07 1.02-.065 2.047-.13 3.064-.244 1.16-.13 2.306-.34 3.46-.508l.127-.018c.57-.075 1.14-.152 1.711-.23l-.356.049c1.314-.177 2.624-.38 3.935-.581.576-.089 1.153-.169 1.73-.258.565-.088 1.129-.184 1.695-.269.568-.07 1.139-.133 1.703-.224l1.858-.298c.994-.16 1.983-.366 2.966-.59l1.533-.348c.53-.12 1.06-.252 1.592-.357 1.202-.236 2.403-.47 3.606-.7 1.006-.191 2.01-.401 3.01-.62a39.281 39.281 0 003.178-.86 93.757 93.757 0 003.167-1.043c.457-.158.908-.337 1.36-.506a487.71 487.71 0 001.69-.63c.593-.224 1.185-.449 1.776-.68.58-.225 1.148-.48 1.717-.729.499-.202 1.007-.397 1.487-.637.3-.15.527-.487.614-.803a1.346 1.346 0 00-.933-1.655l-.356-.05a1.326 1.326 0 00-.674.185l-.15.066c-.622.255-1.237.526-1.854.794-.965.396-1.944.758-2.92 1.125-.983.371-1.967.745-2.96 1.089-2.045.71-4.123 1.373-6.242 1.818-1.058.222-2.118.439-3.18.641-.507.098-1.015.194-1.523.294-.562.11-1.13.206-1.69.333l-3.471.788c-1.022.232-2.057.41-3.09.576l-1.161.186c-.707.091-1.414.183-2.12.285-.703.102-1.402.229-2.104.333-1.36.204-2.719.41-4.08.612l-.926.126.356-.049c-1.32.179-2.635.37-3.95.583-.939.112-1.88.179-2.824.248-.54.04-1.079.081-1.621.099a50.12 50.12 0 00-1.507.073c-.51.033-1.014.06-1.524.049a46.06 46.06 0 01-1.529-.062c-.5-.026-1-.07-1.5-.098-.514-.029-1.027-.024-1.54-.025a130.154 130.154 0 01-3.29-.039 35.933 35.933 0 01-1.627-.076 31.11 31.11 0 01-1.552-.16c-.907-.134-1.81-.297-2.72-.42a189.16 189.16 0 00-3.194-.41c-.978-.116-1.954-.249-2.932-.363-.973-.114-1.95-.182-2.922-.289-.7-.1-1.4-.21-2.098-.329-.824-.14-1.637-.357-2.448-.553-.836-.202-1.664-.43-2.493-.658-.406-.112-.808-.241-1.211-.363-.354-.106-.698-.24-1.045-.366l-1.21-.52a35.093 35.093 0 01-1.903-.884c-.67-.337-1.334-.683-2.005-1.017-.665-.33-1.331-.66-1.998-.988a42.286 42.286 0 00-2.05-.93l-.03-.014-.065-.027-.037-.015-.075-.033.011.005c-.46-.195-.923-.385-1.386-.576-1.002-.44-1.98-.92-2.963-1.4-.541-.264-1.071-.531-1.571-.87-.481-.327-.937-.697-1.398-1.05a691.29 691.29 0 01-2.02-1.583l-.259-.202a1.284 1.284 0 00-1.556.202M.345 73.667a1.125 1.125 0 00-.326.791c0 .315.129.558.326.79.398.472.817.926 1.255 1.36.186.185.371.372.56.555.214.207.42.427.646.619.28.236.555.48.847.701l.76.574c.194.146.402.273.604.408.221.147.441.293.668.432.965.587 1.945 1.153 2.964 1.642 1.06.508 2.177.906 3.277 1.32.515.193 1.032.383 1.545.585.49.193.98.386 1.475.567.505.184 1.039.304 1.557.443.492.133.985.262 1.477.39 1.042.272 2.087.529 3.128.807 1.045.279 2.086.578 3.119.903 1.99.626 4 1.21 6.036 1.67 1.053.237 2.106.47 3.156.723.499.12 1 .234 1.5.348.612.14 1.236.222 1.857.302 1.061.136 2.122.261 3.19.33a60.01 60.01 0 003.115.108c.513.006 1.026.008 1.539.015.515.007 1.032.044 1.548.07 1.093.055 2.19.07 3.281.147 1.043.074 2.084.128 3.13.159 1.1.032 2.2.033 3.302.038 1.112.005 2.225.014 3.337-.032a52.933 52.933 0 003.227-.236c1.139-.117 2.276-.24 3.412-.383 1.107-.139 2.207-.327 3.311-.489 1.035-.127 2.072-.247 3.108-.37 1.115-.131 2.234-.216 3.353-.314a34.99 34.99 0 003.57-.529c1.118-.217 2.243-.4 3.366-.588 1.708-.286 3.423-.528 5.13-.819.808-.137 1.618-.269 2.426-.407.834-.141 1.67-.257 2.508-.375 1.046-.142 2.088-.299 3.117-.542.583-.137 1.143-.356 1.692-.593.235-.102.462-.213.684-.34.193-.111.371-.246.558-.367.35-.225.698-.458 1.038-.698.407-.288.799-.596 1.207-.883.366-.257.722-.53 1.081-.795.412-.303.828-.608 1.198-.963.409-.393.828-.774 1.24-1.163.173-.163.334-.34.497-.511.206-.216.441-.403.66-.605.538-.497.5-1.405 0-1.908a1.338 1.338 0 00-.948-.395c-.366 0-.681.15-.947.395-.246.227-.503.44-.734.684-.23.241-.469.472-.709.702-.253.242-.516.474-.768.717-.19.182-.382.36-.585.526-.742.563-1.502 1.102-2.25 1.656-.392.29-.8.561-1.208.831-.192.127-.382.26-.577.382a5.58 5.58 0 01-.623.33c-1.201.47-2.473.674-3.743.858a90.654 90.654 0 00-3.163.467c-1.046.178-2.093.354-3.14.525-2.138.352-4.281.662-6.414 1.04-1.019.18-2.032.385-3.054.541a60.39 60.39 0 01-2.438.242c-.91.073-1.814.177-2.72.285-.976.116-1.956.215-2.929.355-.865.124-1.728.261-2.591.393-.973.127-1.946.237-2.922.339a64.39 64.39 0 01-3.285.277c-1.092.058-2.181.07-3.274.065-1.133-.004-2.267-.006-3.4-.026a61.327 61.327 0 01-3.18-.131c-.527-.037-1.053-.078-1.582-.095-.503-.016-1.007-.031-1.51-.056-1.068-.051-2.133-.107-3.203-.114a78.48 78.48 0 01-3.148-.077c-1.254-.06-2.499-.2-3.743-.357a20.493 20.493 0 01-1.473-.274 148.7 148.7 0 01-1.509-.352c-1.01-.24-2.03-.446-3.04-.688a99.71 99.71 0 01-6.1-1.684 73.325 73.325 0 00-3.198-.893c-1.04-.27-2.084-.514-3.126-.775-1.023-.256-2.058-.475-3.058-.813-.987-.334-1.96-.708-2.947-1.044a57.992 57.992 0 01-2.448-.902 25.004 25.004 0 01-2.46-1.225c-.494-.287-.98-.577-1.46-.885-.381-.244-.743-.511-1.108-.779-.197-.158-.398-.313-.58-.486-.243-.23-.488-.456-.726-.69-.248-.243-.51-.478-.744-.734-.31-.338-.624-.672-.92-1.022-.183-.216-.512-.328-.785-.328a1.11 1.11 0 00-.786.328" />
diff --git a/src/components/TaskAnalysisTable/TaskAnalysisTable.js b/src/components/TaskAnalysisTable/TaskAnalysisTable.js
index 5920b1e2..b5197b58 100644
--- a/src/components/TaskAnalysisTable/TaskAnalysisTable.js
+++ b/src/components/TaskAnalysisTable/TaskAnalysisTable.js
@@ -19,7 +19,6 @@ import _reverse from 'lodash/reverse'
 import _keys from 'lodash/keys'
 import _concat from 'lodash/concat'
 import _filter from 'lodash/filter'
-import _find from 'lodash/find'
 import _cloneDeep from 'lodash/cloneDeep'
 import _split from 'lodash/split'
 import _isEmpty from 'lodash/isEmpty'
@@ -194,22 +193,11 @@ export class TaskAnalysisTable extends Component {
       }
     }
 
-    // It's possible for the props.selectedTasks (which comes from WithFilteredClusteredTasks)
-    // to contain tasks not in our data as our data has been filtered by
-    // bounds and paging also --- so we make sure here to only include tasks
-    // visible on our current page.
-    const selectedDataTasks =
-      _filter([...this.props.selectedTasks.keys()],
-               taskId => _find(data, task => task.id === taskId))
-
-    // If this is for bundling then we really do want to the full count of
-    // selected tasks since they could have used the "lasso" tool.
-    const selectedTaskCount = this.props.forBundling ?
-      this.props.selectedTasks.size : selectedDataTasks.length
-
     if (_get(this.props, 'criteria.filters')) {
-      defaultFiltered = _map(this.props.criteria.filters,
-                             (value, key) => {return {id: key, value}})
+      defaultFiltered = _map(
+        this.props.criteria.filters,
+        (value, key) => ({id: key, value})
+      )
     }
 
     const manager = AsManager(this.props.user)
@@ -224,8 +212,6 @@ export class TaskAnalysisTable extends Component {
                {...this.props}
                countShown={data.length}
                configureColumns={this.configureColumns.bind(this)}
-               selectedTasks={selectedDataTasks}
-               selectedTaskCount={selectedTaskCount}
              />
            </header>
           }
@@ -282,7 +268,7 @@ const setupColumnTypes = (props, taskBaseRoute, manager, data, openComments) =>
 
   columns.selected = {id: 'selected',
     Header: null,
-    accessor: task => (props.selectedTasks.has(task.id) || props.allTasksAreSelected()),
+    accessor: task => props.isTaskSelected(task.id),
     Cell: ({value, original}) => (
       props.highlightPrimaryTask && original.id === props.task.id ?
       <span className="mr-text-green-lighter">✓</span> :
@@ -601,7 +587,7 @@ TaskAnalysisTable.propTypes = {
   challenge: PropTypes.object,
   /** Total tasks available (we may receive a subset) */
   totalTaskCount: PropTypes.number,
-  /** Map of currently selected tasks */
+  /** Currently selected tasks */
   selectedTasks: PropTypes.object.isRequired,
   /** Invoked to toggle selection of a task */
   toggleTaskSelection: PropTypes.func.isRequired,
diff --git a/src/components/TaskAnalysisTable/TaskAnalysisTableHeader.js b/src/components/TaskAnalysisTable/TaskAnalysisTableHeader.js
index 68938670..fd72134a 100644
--- a/src/components/TaskAnalysisTable/TaskAnalysisTableHeader.js
+++ b/src/components/TaskAnalysisTable/TaskAnalysisTableHeader.js
@@ -6,25 +6,17 @@ import _noop from 'lodash/noop'
 import _get from 'lodash/get'
 import _map from 'lodash/map'
 import _omit from 'lodash/omit'
-import _isArray from 'lodash/isArray'
-import _filter from 'lodash/filter'
 import AsManager from '../../interactions/User/AsManager'
 import Dropdown from '../Dropdown/Dropdown'
 import SvgSymbol from '../SvgSymbol/SvgSymbol'
 import TriStateCheckbox from '../Bulma/TriStateCheckbox'
 import ConfirmAction from '../ConfirmAction/ConfirmAction'
-import DropdownButton from '../Bulma/DropdownButton'
 import TimezonePicker from '../TimezonePicker/TimezonePicker'
-import WithDeactivateOnOutsideClick from '../HOCs/WithDeactivateOnOutsideClick/WithDeactivateOnOutsideClick'
-import { TaskStatus, statusLabels, keysByStatus } from '../../services/Task/TaskStatus/TaskStatus'
-import { TaskReviewStatusWithUnset, reviewStatusLabels, keysByReviewStatus } from '../../services/Task/TaskReview/TaskReviewStatus'
+import { TaskStatus, statusLabels } from '../../services/Task/TaskStatus/TaskStatus'
 import { buildLinkToMapperExportCSV } from '../../services/Task/TaskReview/TaskReview'
-import { TaskPriority, taskPriorityLabels, keysByPriority } from '../../services/Task/TaskPriority/TaskPriority'
 import { buildLinkToExportCSV, buildLinkToExportGeoJSON } from '../../services/Challenge/Challenge'
 import messages from './Messages'
 
-const DeactivatableDropdownButton = WithDeactivateOnOutsideClick(DropdownButton)
-
 /**
  * TaskAnalysisTableHeader renders a header for the task analysis table.
  *
@@ -33,109 +25,33 @@ const DeactivatableDropdownButton = WithDeactivateOnOutsideClick(DropdownButton)
 export class TaskAnalysisTableHeader extends Component {
     state = {}
 
-    takeTaskSelectionAction = action => {
-        if (action.statusAction) {
-          this.props.selectTasksWithStatus(action.status)
-        }
-        else if (action.priorityAction) {
-          this.props.selectTasksWithPriority(action.priority)
-        }
-        else if (action.shownAction) {
-          this.props.selectTasks(this.props.taskInfo.tasks)
-        }
-    }
-
     render() {
         const {countShown, configureColumns} = this.props
 
-        const selectedCount = this.props.selectedTaskCount
         const totalTaskCount = _get(this.props, 'totalTaskCount') || countShown || 0
         const totalTasksInChallenge = _get(this.props, 'totalTasksInChallenge', 0)
-        const percentShown = Math.round(totalTaskCount / totalTasksInChallenge * 100.0)
+        const percentShown =
+          totalTasksInChallenge > 0 ?
+          Math.round(totalTaskCount / totalTasksInChallenge * 100.0) :
+          0
         const manager = AsManager(this.props.user)
         const localizedStatusLabels = statusLabels(this.props.intl)
-        const localizedReviewStatusLabels = reviewStatusLabels(this.props.intl)
-        const localizedPriorityLabels = taskPriorityLabels(this.props.intl)
-
-        const taskSelectionStatuses = _isArray(this.props.taskSelectionStatuses) ?
-                                      this.props.taskSelectionStatuses :
-                                      TaskStatus
-
-        const taskSelectionReviewStatuses = _isArray(this.props.taskSelectionReviewStatuses) ?
-                                            this.props.taskSelectionReviewStatuses :
-                                            TaskReviewStatusWithUnset
-
-        const taskSelectionPriorities = _isArray(this.props.taskSelectionPriorities) ?
-                                        this.props.taskSelectionPriorities :
-                                        TaskPriority
-
-        const taskSelectionActions = [{
-          key: 'shown-tasks',
-          text: this.props.intl.formatMessage(messages.shownLabel),
-          shownAction: true,
-        }].concat(
-          _map(taskSelectionStatuses, status => ({
-            key: `status-${status}`,
-            text: localizedStatusLabels[keysByStatus[status]],
-            status,
-            statusAction: true,
-          }))
-        ).concat(
-          _map(taskSelectionReviewStatuses, status => ({
-            key: `review-status-${status}`,
-            text: localizedReviewStatusLabels[keysByReviewStatus[status]],
-            status,
-            statusAction: true,
-          }))
-        ).concat(
-          _map(taskSelectionPriorities, priority => ({
-            key: `priority-${priority}`,
-            text: `${localizedPriorityLabels[keysByPriority[priority]]} ${this.props.intl.formatMessage(messages.priorityLabel)}`,
-            priority,
-            priorityAction: true,
-          }))
-        )
-
-        // Determine if we have tasks that can be marked as review unnecessary
-        let hasReviewRequests = this.props.allTasksAreSelected()
-        let tasks = this.props.taskData || []
-        const selectedTasks = this.props.selectedTasks || []
-        if (!hasReviewRequests) {
-          tasks = _filter(tasks, task =>
-            task.reviewStatus === TaskReviewStatusWithUnset.needed &&
-            selectedTasks.find(id => id === task.id))
-          hasReviewRequests = tasks.length > 0
-        }
-
 
         return (
             <div className="mr-flex mr-justify-between">
                 <div className="mr-flex mr-items-center">
                     {_get(this.props, 'taskInfo.tasks.length', 0) > 0 &&
-                        <div className="mr-mr-4">
-                          <div
-                            className="mr-m-0 mr-flex mr-py-2 mr-pl-4 mr-pr-8 mr-items-center mr-bg-black-15 mr-rounded mr-relative"
-                            title={this.props.intl.formatMessage(messages.bulkSelectionTooltip)}
-                          >
-                            <TriStateCheckbox
-                                checked={this.props.allTasksAreSelected() || (selectedCount === totalTaskCount)}
-                                indeterminate={this.props.someTasksAreSelected() && (selectedCount !== totalTaskCount)}
-                                onClick={() => this.props.toggleAllTasksSelection()}
-                                onChange={_noop}
-                            />
-                            <div className="mr-absolute mr-top-0 mr-right-0 mr-mt-1 mr-mr-2">
-                              <DeactivatableDropdownButton
-                                options={taskSelectionActions}
-                                onSelect={this.takeTaskSelectionAction}
-                              >
-                                <SvgSymbol
-                                  sym="icon-cheveron-down"
-                                  viewBox="0 0 20 20"
-                                  className="mr-fill-green-lighter mr-w-5 mr-h-5"
-                                />
-                              </DeactivatableDropdownButton>
-                            </div>
-                          </div>
+                        <div className="mr-mr-4 mr-ml-12.8">
+                          {!this.props.suppressTriState &&
+                           <div title={this.props.intl.formatMessage(messages.bulkSelectionTooltip)}>
+                             <TriStateCheckbox
+                                 checked={this.props.allTasksAreSelected(totalTaskCount)}
+                                 indeterminate={this.props.someTasksAreSelected(totalTaskCount)}
+                                 onClick={() => this.props.toggleAllTasksSelection()}
+                                 onChange={_noop}
+                             />
+                           </div>
+                          }
                         </div>
                     }
 
@@ -143,7 +59,7 @@ export class TaskAnalysisTableHeader extends Component {
                         <span className="mr-mr-2">
                           <FormattedMessage
                             {...messages.taskCountSelectedStatus}
-                            values={{selectedCount: (this.props.allTasksAreSelected() ? totalTaskCount : selectedCount)}}
+                            values={{selectedCount: this.props.selectedTaskCount(totalTaskCount)}}
                           />
                         </span>
                         <span className="mr-mr-6">
@@ -218,10 +134,7 @@ export class TaskAnalysisTableHeader extends Component {
                                           skipConfirmation={e => e.target.value === ""}
                                         >
                                           <button
-                                            className={classNames("mr-text-current mr-pr-1",
-                                              !hasReviewRequests ? "mr-text-grey-light mr-cursor-default" : "mr-text-green-lighter"
-                                            )}
-                                            disabled={!hasReviewRequests}
+                                            className="mr-text-current mr-pr-1 mr-text-green-lighter"
                                             onClick={() => this.props.removeReviewRequests(this.props.selectedTasks)}
                                           >
                                             <FormattedMessage {...messages.changeReviewStatusLabel} />
diff --git a/src/components/TaskClusterMap/TaskClusterMap.js b/src/components/TaskClusterMap/TaskClusterMap.js
index 08b85dc5..0750384d 100644
--- a/src/components/TaskClusterMap/TaskClusterMap.js
+++ b/src/components/TaskClusterMap/TaskClusterMap.js
@@ -19,7 +19,7 @@ import _compact from 'lodash/compact'
 import _cloneDeep from 'lodash/cloneDeep'
 import _isObject from 'lodash/isObject'
 import _omit from 'lodash/omit'
-import { layerSourceWithId } from '../../services/VisibleLayer/LayerSources'
+import { buildLayerSources } from '../../services/VisibleLayer/LayerSources'
 import { TaskPriorityColors } from '../../services/Task/TaskPriority/TaskPriority'
 import AsMappableCluster from '../../interactions/TaskCluster/AsMappableCluster'
 import AsMappableTask from '../../interactions/Task/AsMappableTask'
@@ -358,9 +358,18 @@ export class TaskClusterMap extends Component {
     }
   }
 
+  deselectTasksInLayers = layers => {
+    if (this.props.onBulkTaskDeselection) {
+      const taskIds = _compact(_map(layers, layer => _get(layer, 'options.icon.options.taskData.taskId')))
+      this.props.onBulkTaskDeselection(taskIds)
+    }
+  }
+
   render() {
-    const overlayLayers = _map(this.props.visibleOverlays, (layerId, index) =>
-      <SourcedTileLayer key={layerId} source={layerSourceWithId(layerId)} zIndex={index + 2} />
+    const overlayLayers = buildLayerSources(
+      this.props.visibleOverlays, _get(this.props, 'user.settings.customBasemaps'),
+      (layerId, index, layerSource) =>
+        <SourcedTileLayer key={layerId} source={layerSource} zIndex={index + 2} />
     )
 
     const canClusterToggle = !!this.props.allowClusterToggle &&
@@ -405,7 +414,19 @@ export class TaskClusterMap extends Component {
         <ZoomControl className="mr-z-10" position='topright' />
         {this.props.showLasso && this.props.onBulkTaskSelection &&
           (!this.props.showAsClusters || this.props.totalTaskCount <= CLUSTER_POINTS) &&
-         <LassoSelectionControl onLassoSelection={this.selectTasksInLayers} />
+          <LassoSelectionControl
+            onLassoSelection={this.selectTasksInLayers}
+            onLassoDeselection={this.deselectTasksInLayers}
+          />
+        }
+        {!this.props.hideSearchControl &&
+          <SearchControl
+            {...this.props}
+            onResultSelected={bounds => {
+              this.currentBounds = toLatLngBounds(bounds)
+              this.props.updateBounds(bounds)
+            }}
+          />
         }
         <VisibleTileLayer {...this.props} zIndex={1} />
         {overlayLayers}
@@ -426,15 +447,6 @@ export class TaskClusterMap extends Component {
           </label>
         }
         <LayerToggle {...this.props} />
-        {!this.props.hideSearchControl &&
-          <SearchControl
-            {...this.props}
-            onResultSelected={bounds => {
-              this.currentBounds = toLatLngBounds(bounds)
-              this.props.updateBounds(bounds)
-            }}
-          />
-        }
         {!!this.props.mapZoomedOut && !this.state.locatingToUser &&
           <ZoomInMessage {...this.props} zoom={this.currentZoom}/>
         }
diff --git a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.js b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.js
index 202e9ab8..0cd7ca47 100644
--- a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.js
+++ b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.js
@@ -54,6 +54,7 @@ export class ActiveTaskControls extends Component {
     comment: "",
     tags: null,
     revisionLoadBy: TaskReviewLoadMethod.all,
+    doneLoadByFromHistory: false,
   }
 
   setComment = comment => this.setState({comment})
@@ -135,7 +136,8 @@ export class ActiveTaskControls extends Component {
       this.props.updateTaskReviewStatus(this.props.task, this.state.submitRevision,
                                         this.state.comment, this.state.tags,
                                         this.state.revisionLoadBy, this.props.history,
-                                        this.props.taskBundle)
+                                        this.props.taskBundle, this.state.requestedNextTask,
+                                        taskStatus)
     }
     else {
       this.props.completeTask(this.props.task, this.props.task.parent.id,
@@ -178,6 +180,7 @@ export class ActiveTaskControls extends Component {
       confirmingStatus: null,
       requestedNextTask: null,
       comment: "", tags: null,
+      doneLoadByFromHistory: false
     })
   }
 
@@ -187,15 +190,9 @@ export class ActiveTaskControls extends Component {
                         this.state.requestedNextTask)
   }
 
-  componentDidUpdate(nextProps) {
-    // Let's set default revisionLoadBy to inbox if we are coming from inbox
-    if (_get(this.props.history, 'location.state.fromInbox') &&
-        this.state.revisionLoadBy !== TaskReviewLoadMethod.inbox) {
-      this.setState({revisionLoadBy: TaskReviewLoadMethod.inbox})
-    }
-
-    if (_get(this.props, 'task.id') !== _get(nextProps, 'task.id')) {
-      this.resetConfirmation()
+  componentDidUpdate(prevProps, prevState) {
+    if (_get(this.props, 'task.id') !== _get(prevProps, 'task.id')) {
+      return this.resetConfirmation()
     }
 
     if (this.state.tags === null && _get(this.props, 'task.tags')) {
@@ -210,7 +207,16 @@ export class ActiveTaskControls extends Component {
       })
       const tags = _map(unfilteredTags, tag => (tag.name ? tag.name : tag)).join(', ')
 
-      this.setState({tags: tags})
+      return this.setState({tags: tags})
+    }
+
+    // Let's set default revisionLoadBy to inbox if we are coming from inbox
+    // and not changing revisionLoadBy state
+    if (_get(this.props.history, 'location.state.fromInbox') &&
+        this.state.revisionLoadBy !== TaskReviewLoadMethod.inbox &&
+        !this.state.doneLoadByFromHistory) {
+      return this.setState({revisionLoadBy: TaskReviewLoadMethod.inbox,
+                            doneLoadByFromHistory: true})
     }
   }
 
@@ -247,7 +253,7 @@ export class ActiveTaskControls extends Component {
     }
     else {
       const allowedProgressions =
-        allowedStatusProgressions(this.props.task.status)
+        allowedStatusProgressions(this.props.task.status, false, needsRevised)
       const isComplete = isCompletionStatus(this.props.task.status)
       const isFinal = isFinalStatus(this.props.task.status)
 
@@ -333,7 +339,7 @@ export class ActiveTaskControls extends Component {
                {...this.props}
                className="mr-mt-1"
                nextTask={this.next}
-               loadBy={needsRevised ? this.state.revisionLoadBy : this.props.taskLoadBy}
+               loadBy={this.props.taskLoadBy}
                chooseLoadBy={(load) => needsRevised ? this.chooseRevisionLoadBy(load) :
                                         this.chooseLoadBy(load)}
                chooseNextTask={this.chooseNextTask}
diff --git a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskCompletionStep1/Messages.js b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskCompletionStep1/Messages.js
index 9211bc5a..dee4b702 100644
--- a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskCompletionStep1/Messages.js
+++ b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskCompletionStep1/Messages.js
@@ -8,4 +8,14 @@ export default defineMessages({
     id: 'Task.controls.step1.revisionNeeded',
     defaultMessage: "This task needs revision. Be sure to check comments for any details.",
   },
+
+  otherOptions: {
+    id: 'Task.controls.step1.otherOptions',
+    defaultMessage: "Other",
+  },
+
+  changeStatusOptions: {
+    id: 'Task.controls.step1.changeStatusOptions',
+    defaultMessage: "Change Status",
+  },
 })
diff --git a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskCompletionStep1/TaskCompletionStep1.js b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskCompletionStep1/TaskCompletionStep1.js
index dfd50bb8..95485f7d 100644
--- a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskCompletionStep1/TaskCompletionStep1.js
+++ b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskCompletionStep1/TaskCompletionStep1.js
@@ -2,6 +2,7 @@ import React, { Component } from 'react'
 import PropTypes from 'prop-types'
 import { FormattedMessage } from 'react-intl'
 import { TaskStatus } from '../../../../../services/Task/TaskStatus/TaskStatus'
+import { TaskReviewStatus } from '../../../../../services/Task/TaskReview/TaskReviewStatus'
 import UserEditorSelector
        from '../../../../UserEditorSelector/UserEditorSelector'
 import Dropdown from '../../../../Dropdown/Dropdown'
@@ -44,7 +45,6 @@ export default class TaskCompletionStep1 extends Component {
           <div className="mr-text-white mr-text-md mr-mt-4">
             <div>
               <FormattedMessage {...messages.revisionNeeded} />
-
             </div>
           </div>
         }
@@ -59,30 +59,36 @@ export default class TaskCompletionStep1 extends Component {
           }
 
           {this.props.allowedProgressions.has(TaskStatus.falsePositive) &&
+            !this.props.needsRevised &&
            <TaskFalsePositiveControl {...this.props} />
           }
 
           {this.props.allowedProgressions.has(TaskStatus.skipped) &&
+            !this.props.needsRevised &&
            <TaskSkipControl {...this.props} />
           }
 
+          {this.props.needsRevised &&
+            <TaskRevisedControl {...this.props} className="" />
+          }
+
           {(this.props.allowedProgressions.has(TaskStatus.fixed) ||
             this.props.allowedProgressions.has(TaskStatus.tooHard) ||
             this.props.allowedProgressions.has(TaskStatus.alreadyFixed)) &&
            <Dropdown
              className="mr-dropdown--fixed mr-w-full"
              dropdownButton={dropdown =>
-               <MoreOptionsButton toggleDropdownVisible={dropdown.toggleDropdownVisible} />
+               <MoreOptionsButton
+                  toggleDropdownVisible={dropdown.toggleDropdownVisible}
+                  label={this.props.needsRevised ?
+                    <FormattedMessage {...messages.changeStatusOptions} /> :
+                    <FormattedMessage {...messages.otherOptions} /> } />
              }
              dropdownContent={dropdown =>
                <ListMoreOptionsItems {...this.props} />
              }
            />
           }
-
-          {this.props.needsRevised &&
-            <TaskRevisedControl {...this.props} className="" />
-          }
         </div>
       </div>
     )
@@ -95,27 +101,37 @@ const MoreOptionsButton = function(props) {
       className="mr-dropdown__button mr-button mr-text-green-lighter mr-w-full"
       onClick={props.toggleDropdownVisible}
     >
-      Other&hellip;
+      {props.label}&hellip;
     </button>
   )
 }
 
 const ListMoreOptionsItems = function(props) {
+  let complete = props.complete
+  if (props.needsRevised) {
+    complete = (status) => props.complete(status, TaskReviewStatus.needed)
+  }
+
   return (
     <ol className="mr-list-dropdown">
+      {props.allowedProgressions.has(TaskStatus.falsePositive) && props.needsRevised &&
+       <li>
+         <TaskFalsePositiveControl {...props} complete={complete} asLink />
+       </li>
+      }
       {props.allowedProgressions.has(TaskStatus.fixed) &&
        <li>
-         <TaskFixedControl {...props} asLink />
+         <TaskFixedControl {...props} complete={complete} asLink />
        </li>
       }
       {props.allowedProgressions.has(TaskStatus.tooHard) &&
        <li>
-         <TaskTooHardControl {...props} asLink />
+         <TaskTooHardControl {...props} complete={complete} asLink />
        </li>
       }
       {props.allowedProgressions.has(TaskStatus.alreadyFixed) &&
        <li>
-         <TaskAlreadyFixedControl {...props} asLink />
+         <TaskAlreadyFixedControl {...props} complete={complete} asLink />
        </li>
       }
     </ol>
diff --git a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskCompletionStep2/TaskCompletionStep2.js b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskCompletionStep2/TaskCompletionStep2.js
index 6691f529..99c26460 100644
--- a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskCompletionStep2/TaskCompletionStep2.js
+++ b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/TaskCompletionStep2/TaskCompletionStep2.js
@@ -1,6 +1,7 @@
 import React, { Component } from 'react'
 import PropTypes from 'prop-types'
 import { TaskStatus } from '../../../../../services/Task/TaskStatus/TaskStatus'
+import { TaskReviewStatus } from '../../../../../services/Task/TaskReview/TaskReviewStatus'
 import TaskFixedControl from '../TaskFixedControl/TaskFixedControl'
 import TaskTooHardControl from '../TaskTooHardControl/TaskTooHardControl'
 import TaskAlreadyFixedControl from '../TaskAlreadyFixedControl/TaskAlreadyFixedControl'
@@ -25,23 +26,33 @@ export default class TaskCompletionStep2 extends Component {
   }
 
   render() {
+    let complete = this.props.complete
+    if (this.props.needsRevised) {
+      complete = (status) => this.props.complete(status, TaskReviewStatus.needed)
+    }
+
     return (
       <div>
         <div className="mr-my-4 mr-grid mr-grid-columns-2 mr-grid-gap-4">
           {this.props.allowedProgressions.has(TaskStatus.fixed) &&
-            <TaskFixedControl {...this.props} />
+            <TaskFixedControl {...this.props} complete={complete} />
           }
 
           {this.props.allowedProgressions.has(TaskStatus.tooHard) &&
-            <TaskTooHardControl {...this.props} />
+            <TaskTooHardControl {...this.props} complete={complete} />
           }
 
           {this.props.allowedProgressions.has(TaskStatus.alreadyFixed) &&
-            <TaskAlreadyFixedControl {...this.props} />
+            <TaskAlreadyFixedControl {...this.props} complete={complete} />
+          }
+
+          {this.props.allowedProgressions.has(TaskStatus.falsePositive) &&
+            <TaskFalsePositiveControl {...this.props} complete={complete} />
           }
 
           {(this.props.allowedProgressions.has(TaskStatus.skipped) ||
             this.props.allowedProgressions.has(TaskStatus.falsePositive)) &&
+            !this.props.needsRevised &&
            <Dropdown
              className="mr-dropdown--fixed mr-w-full"
              dropdownButton={dropdown =>
diff --git a/src/components/TaskPane/TaskMap/TaskMap.js b/src/components/TaskPane/TaskMap/TaskMap.js
index c1cf3f34..8ec807bb 100644
--- a/src/components/TaskPane/TaskMap/TaskMap.js
+++ b/src/components/TaskPane/TaskMap/TaskMap.js
@@ -4,18 +4,23 @@ import classNames from 'classnames'
 import { ZoomControl, LayerGroup, Marker, Popup } from 'react-leaflet'
 import L from 'leaflet'
 import { featureCollection } from '@turf/helpers'
+import { coordAll } from '@turf/meta'
+import { point } from '@turf/helpers'
 import _isObject from 'lodash/isObject'
 import _get from 'lodash/get'
 import _isEqual from 'lodash/isEqual'
 import _isFinite from 'lodash/isFinite'
 import _map from 'lodash/map'
 import _pick from 'lodash/pick'
+import _each from 'lodash/each'
 import _compact from 'lodash/compact'
 import _flatten from 'lodash/flatten'
 import _isEmpty from 'lodash/isEmpty'
 import _clone from 'lodash/clone'
-import { layerSourceWithId } from '../../../services/VisibleLayer/LayerSources'
+import { buildLayerSources } from '../../../services/VisibleLayer/LayerSources'
 import EnhancedMap from '../../EnhancedMap/EnhancedMap'
+import DirectionalIndicationMarker
+       from '../../EnhancedMap/DirectionalIndicationMarker/DirectionalIndicationMarker'
 import MapillaryViewer from '../../MapillaryViewer/MapillaryViewer'
 import OpenStreetCamViewer from '../../OpenStreetCamViewer/OpenStreetCamViewer'
 import SourcedTileLayer
@@ -71,6 +76,7 @@ export class TaskMap extends Component {
     openStreetCamViewerImage: null,
     skipFit: false,
     latestZoom: null,
+    directionalityIndicators: [],
   }
 
   /** Process keyboard shortcuts for the layers */
@@ -225,6 +231,7 @@ export class TaskMap extends Component {
 
     this.loadMapillaryIfNeeded()
     this.loadOpenStreetCamIfNeeded()
+    this.generateDirectionalityMarkers()
   }
 
   componentDidUpdate(prevProps) {
@@ -234,6 +241,10 @@ export class TaskMap extends Component {
     if (_get(this.props, 'task.id') !== _get(prevProps, 'task.id')) {
       this.deactivateOSMDataLayer()
       this.setState({skipFit: false})
+      this.generateDirectionalityMarkers()
+    }
+    else if (!_isEqual(_get(prevProps, 'task.geometries'), _get(this.props, 'task.geometries'))) {
+      this.generateDirectionalityMarkers()
     }
   }
 
@@ -242,7 +253,8 @@ export class TaskMap extends Component {
     // task or our internal state changes. We care about changes to the task
     // id, its geometries, layer options, and a few settings on the parent
     // challenge.
-    if (nextState.showTaskFeatures !== this.state.showTaskFeatures) {
+    if (nextState.showTaskFeatures !== this.state.showTaskFeatures ||
+        nextState.directionalityIndicators !== this.state.directionalityIndicators) {
       return true
     }
 
@@ -397,6 +409,46 @@ export class TaskMap extends Component {
     return <LayerGroup key={Date.now()}>{markers}</LayerGroup>
   }
 
+  generateDirectionalityMarkers = () => {
+    const markers = []
+    const allFeatures = this.taskFeatures()
+    _each(allFeatures, (feature, featureIndex) => {
+      if (!feature.properties || !feature.properties.oneway) {
+        return
+      }
+
+      const styles =
+        AsSimpleStyleableFeature(feature, _get(this.props, 'challenge.taskStyles')).getFinalLayerStyles()
+      const coords = coordAll(feature)
+      if (["yes", "true", "1"].indexOf(feature.properties.oneway) !== -1) {
+        for (let i = 0; i < coords.length - 1; i++) {
+          markers.push(
+            <DirectionalIndicationMarker
+              key={`directional-marker-${this.props.task.id}-${featureIndex}-${i}`}
+              betweenPoints={[point(coords[i]), point(coords[i + 1])]}
+              atMidpoint
+              styles={styles}
+            />
+          )
+        }
+      }
+      else if (["-1", "reverse"].indexOf(feature.properties.oneway) !== -1) {
+        for (let i = coords.length - 1; i > 0; i--) {
+          markers.push(
+            <DirectionalIndicationMarker
+              key={`directional-marker-${this.props.task.id}-${featureIndex}-${i}`}
+              betweenPoints={[point(coords[i]), point(coords[i - 1])]}
+              atMidpoint
+              styles={styles}
+            />
+          )
+        }
+      }
+    })
+
+    this.setState({directionalityIndicators: markers})
+  }
+
   taskFeatures = () => {
     if (_get(this.props, 'taskBundle.tasks.length', 0) > 0) {
       return featureCollection(
@@ -439,8 +491,10 @@ export class TaskMap extends Component {
       return <BusySpinner />
     }
 
-    const overlayLayers = _map(this.props.visibleOverlays, (layerId, index) =>
-      <SourcedTileLayer key={layerId} source={layerSourceWithId(layerId)} zIndex={index + 2} />
+    const overlayLayers = buildLayerSources(
+      this.props.visibleOverlays, _get(this.props, 'user.settings.customBasemaps'),
+      (layerId, index, layerSource) =>
+        <SourcedTileLayer key={layerId} source={layerSource} zIndex={index + 2} />
     )
 
     const mapillaryMarkers = this.props.showMapillaryLayer ?
@@ -503,6 +557,7 @@ export class TaskMap extends Component {
           }
           {this.props.showMapillaryLayer && mapillaryMarkers}
           {this.props.showOpenStreetCamLayer && openStreetCamMarkers}
+          {this.state.showTaskFeatures && this.state.directionalityIndicators}
         </EnhancedMap>
 
         {this.state.mapillaryViewerImage &&
diff --git a/src/components/TaskPane/TaskNearbyList/Messages.js b/src/components/TaskPane/TaskNearbyList/Messages.js
index 628cf7bc..9a472648 100644
--- a/src/components/TaskPane/TaskNearbyList/Messages.js
+++ b/src/components/TaskPane/TaskNearbyList/Messages.js
@@ -23,4 +23,9 @@ export default defineMessages({
     id: "Widgets.TaskNearbyMap.tooltip.statusLabel",
     defaultMessage: "Status: ",
   },
+
+  loadMoreTasks: {
+    id: "Widgets.TaskNearbyMap.tooltip.loadMoreTasks.control",
+    defaultMessage: "Load More Tasks",
+  },
 })
diff --git a/src/components/TaskPane/TaskNearbyList/TaskNearbyMap.js b/src/components/TaskPane/TaskNearbyList/TaskNearbyMap.js
index 5caa3abe..078431af 100644
--- a/src/components/TaskPane/TaskNearbyList/TaskNearbyMap.js
+++ b/src/components/TaskPane/TaskNearbyList/TaskNearbyMap.js
@@ -12,7 +12,7 @@ import _get from 'lodash/get'
 import _map from 'lodash/map'
 import _cloneDeep from 'lodash/cloneDeep'
 import { latLng } from 'leaflet'
-import { layerSourceWithId } from '../../../services/VisibleLayer/LayerSources'
+import { buildLayerSources } from '../../../services/VisibleLayer/LayerSources'
 import { TaskStatusColors, messagesByStatus } from '../../../services/Task/TaskStatus/TaskStatus'
 import { messagesByPriority } from '../../../services/Task/TaskPriority/TaskPriority'
 import AsMappableTask from '../../../interactions/Task/AsMappableTask'
@@ -49,19 +49,19 @@ const starIconSvg = L.vectorIcon({
   },
 })
 
-const markerIconSvg = (fillColor=colors['blue-leaflet'], priority) => L.vectorIcon({
+const markerIconSvg = (priority, styleOptions={}) => L.vectorIcon({
   viewBox: '0 0 20 20',
-  svgHeight: 40 - (priority * 10),
+  svgHeight: 45 - (priority * 10),
   svgWidth: 40 - (priority * 10),
   type: 'path',
   shape: { // zondicons "location" icon
     d: "M10 20S3 10.87 3 7a7 7 0 1 1 14 0c0 3.87-7 13-7 13zm0-11a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"
   },
-  style: {
-    fill: fillColor,
+  style: Object.assign({
+    fill: colors['blue-leaflet'],
     stroke: colors['grey-leaflet'],
     strokeWidth: 0.5,
-  },
+  }, styleOptions),
   iconAnchor: [5, 15], // render tip of SVG near marker location
 })
 
@@ -116,14 +116,17 @@ export class TaskNearbyMap extends Component {
         const isRequestedMarker = marker.options.taskId === this.props.requestedNextTask
         const markerData = _cloneDeep(marker)
         markerData.options.title = `Task ${marker.options.taskId}`
+        const markerStyle = {
+          fill: TaskStatusColors[_get(marker.options, 'status', 0)],
+          stroke: isRequestedMarker ? colors.yellow : colors['grey-leaflet'],
+          strokeWidth: isRequestedMarker ? 2 : 0.5,
+        }
 
         return (
           <Marker
             key={marker.options.taskId}
             {...markerData}
-            icon={markerIconSvg(isRequestedMarker ? colors.yellow :
-              TaskStatusColors[_get(marker.options, 'status', 0)],
-              _get(marker.options, 'priority', 0))}
+            icon={markerIconSvg(_get(marker.options, 'priority', 0), markerStyle)}
             zIndexOffset={isRequestedMarker ? 1000 : undefined}
             onClick={() => this.markerClicked(markerData)}
           >
@@ -146,8 +149,10 @@ export class TaskNearbyMap extends Component {
       })
     }
 
-    const overlayLayers = _map(this.props.visibleOverlays, (layerId, index) =>
-      <SourcedTileLayer key={layerId} source={layerSourceWithId(layerId)} zIndex={index + 2} />
+    const overlayLayers = buildLayerSources(
+      this.props.visibleOverlays, _get(this.props, 'user.settings.customBasemaps'),
+      (layerId, index, layerSource) =>
+        <SourcedTileLayer key={layerId} source={layerSource} zIndex={index + 2} />
     )
 
     if (!coloredMarkers) {
@@ -163,7 +168,7 @@ export class TaskNearbyMap extends Component {
         <LayerToggle {...this.props} />
         <EnhancedMap
           onClick={this.props.clearNextTask}
-          center={latLng(0, 45)} zoom={3} minZoom={2} maxZoom={18}
+          center={latLng(0, 45)} zoom={3} minZoom={2} maxZoom={19}
           setInitialBounds={false}
           zoomControl={false} animate={true} worldCopyJump={true}
           features={bounding}
@@ -183,6 +188,15 @@ export class TaskNearbyMap extends Component {
            </MarkerClusterGroup>
           }
         </EnhancedMap>
+        {this.props.hasMoreToLoad &&
+          <div className="mr-absolute mr-bottom-0 mr-mb-8 mr-w-full mr-text-center">
+            <button
+              className="mr-button mr-button--small mr-button--blue-fill"
+              onClick={() => this.props.increaseTaskLimit()}>
+                <FormattedMessage {...messages.loadMoreTasks} />
+            </button>
+          </div>
+        }
 
         {!!this.props.tasksLoading && <BusySpinner mapMode big />}
       </div>
diff --git a/src/components/Widgets/ReviewStatusMetricsWidget/ReviewStatusMetricsWidget.js b/src/components/Widgets/ReviewStatusMetricsWidget/ReviewStatusMetricsWidget.js
index 3111e08d..630bda2b 100644
--- a/src/components/Widgets/ReviewStatusMetricsWidget/ReviewStatusMetricsWidget.js
+++ b/src/components/Widgets/ReviewStatusMetricsWidget/ReviewStatusMetricsWidget.js
@@ -1,9 +1,16 @@
 import React, { Component } from 'react'
 import { FormattedMessage } from 'react-intl'
+import _indexOf from 'lodash/indexOf'
+import _isEqual from 'lodash/isEqual'
 import { WidgetDataTarget, registerWidgetType }
        from '../../../services/Widget/Widget'
 import ReviewStatusMetrics from '../../../pages/Review/Metrics/ReviewStatusMetrics'
 import WithReviewMetrics from '../../HOCs/WithReviewMetrics/WithReviewMetrics'
+import WithChallengeReviewMetrics
+      from '../../AdminPane/HOCs/WithChallengeReviewMetrics/WithChallengeReviewMetrics'
+import WithProjectReviewMetrics
+      from '../../AdminPane/HOCs/WithProjectReviewMetrics/WithProjectReviewMetrics'
+
 import QuickWidget from '../../QuickWidget/QuickWidget'
 import messages from './Messages'
 
@@ -22,6 +29,8 @@ const descriptor = {
 }
 
 export default class ReviewStatusMetricsWidget extends Component {
+  ReviewStatusMetricsComponent = null
+
   setShowByPriority = showByPriority => {
     this.props.updateWidgetConfiguration({showByPriority: !!showByPriority})
   }
@@ -30,7 +39,37 @@ export default class ReviewStatusMetricsWidget extends Component {
     this.props.updateWidgetConfiguration({showByTaskStatus: !!showByTaskStatus})
   }
 
+  setupReviewMetricsHOC = targets => {
+    if (targets === WidgetDataTarget.review ||
+        _indexOf(targets, WidgetDataTarget.review) > -1) {
+      return WithReviewMetrics(ReviewStatusMetrics)
+    }
+
+    if (targets === WidgetDataTarget.challenge ||
+        _indexOf(targets, WidgetDataTarget.challenge) > -1) {
+      return WithChallengeReviewMetrics(ReviewStatusMetrics)
+    }
+
+    if (targets === WidgetDataTarget.project ||
+        _indexOf(targets, WidgetDataTarget.project) > -1) {
+      return WithProjectReviewMetrics(ReviewStatusMetrics)
+    }
+  }
+
+  componentDidMount() {
+    this.ReviewStatusMetricsComponent = this.setupReviewMetricsHOC(this.props.targets)
+  }
+
+  componentDidUpdate(prevProps) {
+    if (!_isEqual(prevProps.targets, this.props.targets)) {
+      this.ReviewStatusMetricsComponent = this.setupReviewMetricsHOC(this.props.targets)
+    }
+  }
+
   render() {
+    // Wait until our props are setup
+    if (!this.ReviewStatusMetricsComponent) return null
+
     return (
       <QuickWidget
         {...this.props}
@@ -40,7 +79,7 @@ export default class ReviewStatusMetricsWidget extends Component {
         }
         noMain
       >
-        <ReviewStatusMetrics
+        <this.ReviewStatusMetricsComponent
           {...this.props}
           showByPriority={this.props.widgetConfiguration.showByPriority}
           setShowByPriority={this.setShowByPriority}
@@ -52,4 +91,4 @@ export default class ReviewStatusMetricsWidget extends Component {
   }
 }
 
-registerWidgetType(WithReviewMetrics(ReviewStatusMetricsWidget), descriptor)
+registerWidgetType(ReviewStatusMetricsWidget, descriptor)
diff --git a/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js b/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js
index 1844e874..ecb15c20 100644
--- a/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js
+++ b/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js
@@ -4,7 +4,6 @@ import { Popup } from 'react-leaflet'
 import _get from 'lodash/get'
 import _isFinite from 'lodash/isFinite'
 import _isEqual from 'lodash/isEqual'
-import _uniq from 'lodash/uniq'
 import _sum from 'lodash/sum'
 import _values from 'lodash/values'
 import _pick from 'lodash/pick'
@@ -18,6 +17,8 @@ import TaskClusterMap from '../../TaskClusterMap/TaskClusterMap'
 import TaskPropertyFilter from '../../TaskFilters/TaskPropertyFilter'
 import TaskPriorityFilter from '../../TaskFilters/TaskPriorityFilter'
 import TaskStatusFilter from '../../TaskFilters/TaskStatusFilter'
+import WithSelectedClusteredTasks
+       from '../../HOCs/WithSelectedClusteredTasks/WithSelectedClusteredTasks'
 import WithBrowsedChallenge from '../../HOCs/WithBrowsedChallenge/WithBrowsedChallenge'
 import WithNearbyTasks from '../../HOCs/WithNearbyTasks/WithNearbyTasks'
 import WithTaskClusterMarkers from '../../HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers'
@@ -34,6 +35,7 @@ import WithWebSocketSubscriptions
        from '../../HOCs/WithWebSocketSubscriptions/WithWebSocketSubscriptions'
 import { TaskStatus } from '../../../services/Task/TaskStatus/TaskStatus'
 import { TaskAction } from '../../../services/Task/TaskAction/TaskAction'
+import { toLatLngBounds } from '../../../services/MapBounds/MapBounds'
 import QuickWidget from '../../QuickWidget/QuickWidget'
 import BusySpinner from '../../BusySpinner/BusySpinner'
 import TaskAnalysisTable from '../../TaskAnalysisTable/TaskAnalysisTable'
@@ -59,14 +61,18 @@ const descriptor = {
 }
 
 const ClusterMap = WithChallengeTaskClusters(
-                     WithTaskClusterMarkers(TaskClusterMap('taskBundling')))
+  WithTaskClusterMarkers(TaskClusterMap('taskBundling')),
+  false,
+  false
+)
 
 
 export default class TaskBundleWidget extends Component {
   bundleTasks = () => {
-    this.props.createTaskBundle(
-      _uniq([this.props.task.id].concat([...this.props.selectedTasks.keys()]))
-    )
+    // Because there's no way to select all tasks (TriState checkbox is
+    // suppressed on the TaskAnalysisTables), we only need to worry about
+    // explicitly selected tasks
+    this.props.createTaskBundle([...this.props.selectedTasks.selected.keys()])
   }
 
   /**
@@ -93,6 +99,7 @@ export default class TaskBundleWidget extends Component {
   unbundleTasks = async () => {
     this.props.removeTaskBundle(this.props.taskBundle.bundleId, this.props.task.id)
     this.props.resetSelectedTasks()
+    this.setBoundsToNearbyTask()
   }
 
   updateBounds = (challengeId, bounds, zoom) => {
@@ -128,6 +135,10 @@ export default class TaskBundleWidget extends Component {
       this.initializeClusterFilters()
       this.initializeWebsocketSubscription()
     }
+
+    if (this.props.task && this.props.selectedTasks && !this.props.isTaskSelected(this.props.task.id)) {
+      this.props.selectTasks([this.props.task])
+    }
   }
 
   componentDidUpdate(prevProps) {
@@ -141,6 +152,9 @@ export default class TaskBundleWidget extends Component {
         this.props.task.id !== prevProps.task.id) {
       this.props.resetSelectedTasks()
     }
+    else if (this.props.task && this.props.selectedTasks && !this.props.isTaskSelected(this.props.task.id)) {
+      this.props.selectTasks([this.props.task])
+    }
   }
 
   componentWillUnmount() {
@@ -192,7 +206,7 @@ const ActiveBundle = props => {
   return (
     <div className="mr-p-4 mr-h-full mr-rounded">
       <div className="mr-flex mr-justify-between mr-content-center mr-mb-8">
-        <h3 className="mr-text-lg mr-text-blue">
+        <h3 className="mr-text-lg mr-text-pink-light">
           <FormattedMessage
             {...messages.simultaneousTasks}
             values={{taskCount: props.taskBundle.taskIds.length}}
@@ -226,6 +240,7 @@ const ActiveBundle = props => {
         taskSelectionReviewStatuses={[]}
         suppressHeader
         suppressManagement
+        suppressTriState
         defaultPageSize={5}
       />
     </div>
@@ -264,11 +279,8 @@ const BuildBundle = props => {
     )
   }
 
-  // Force the current task to always show as selected
-  const selectedTasks = new Map(props.selectedTasks)
-  selectedTasks.set(props.task.id, props.task)
-
-  const bundleButton = selectedTasks.size > 1 ? (
+  const totalTaskCount = _get(props, 'taskInfo.totalCount') || _get(props, 'taskInfo.tasks.length')
+  const bundleButton = props.selectedTaskCount(totalTaskCount) > 1 ? (
     <button
       className="mr-button mr-button--green-lighter mr-button--small"
       onClick={props.bundleTasks}
@@ -281,8 +293,11 @@ const BuildBundle = props => {
     return (
       <Popup key={markerData.options.taskId}>
         <div className="marker-popup-content">
-          <TaskMarkerContent marker={markerData} taskId={markerData.options.taskId}
-                    selectedTasks={selectedTasks} {..._omit(props, 'selectedTasks')} />
+          <TaskMarkerContent
+            {...props}
+            marker={markerData}
+            taskId={markerData.options.taskId}
+          />
         </div>
       </Popup>
     )
@@ -293,12 +308,13 @@ const BuildBundle = props => {
       loadingTasks={props.loadingTasks}
       showMarkerPopup={showMarkerPopup}
       highlightPrimaryTask={props.task.id}
-      chosenTasks={selectedTasks}
       boundingBox={_get(props, 'criteria.boundingBox')}
-      onBulkTaskSelection={props.selectTasksById}
+      initialBounds={toLatLngBounds(_get(props, 'criteria.boundingBox', []))}
+      onBulkTaskSelection={props.selectTasks}
+      onBulkTaskDeselection={props.deselectTasks}
       allowClusterToggle={false}
       hideSearchControl
-      {..._omit(props, 'selectedTasks', 'className')}
+      {..._omit(props, 'className')}
     />
 
   return (
@@ -325,9 +341,8 @@ const BuildBundle = props => {
       <div className="mr-px-4 mr-h-half mr-overflow-y-auto">
         <TaskAnalysisTable
           {...props}
-          selectedTasks={selectedTasks}
           taskData={_get(props, 'taskInfo.tasks')}
-          totalTaskCount={_get(props, 'taskInfo.totalCount') || _get(props, 'taskInfo.tasks.length')}
+          totalTaskCount={totalTaskCount}
           totalTasksInChallenge={ calculateTasksInChallenge(props) }
           showColumns={['selected', 'featureId', 'id', 'status', 'priority']}
           taskSelectionStatuses={[TaskStatus.created, TaskStatus.skipped, TaskStatus.tooHard]}
@@ -338,6 +353,7 @@ const BuildBundle = props => {
           highlightPrimaryTask
           defaultPageSize={5}
           forBundling
+          suppressTriState
         />
       </div>
     </div>
@@ -345,28 +361,30 @@ const BuildBundle = props => {
 }
 
 registerWidgetType(
-  WithNearbyTasks(
-    WithClusteredTasks(
-      WithFilteredClusteredTasks(
-        WithTaskPropertyKeys(
-          WithFilterCriteria(
-            WithBoundedTasks(
-              WithBrowsedChallenge(
-                WithWebSocketSubscriptions(
-                  TaskBundleWidget,
-                )
-              ),
-              'filteredClusteredTasks',
-              'taskInfo'
+  WithSelectedClusteredTasks(
+    WithNearbyTasks(
+      WithClusteredTasks(
+        WithFilteredClusteredTasks(
+          WithTaskPropertyKeys(
+            WithFilterCriteria(
+              WithBoundedTasks(
+                WithBrowsedChallenge(
+                  WithWebSocketSubscriptions(
+                    TaskBundleWidget,
+                  )
+                ),
+                'filteredClusteredTasks',
+                'taskInfo'
+              )
             )
-          )
-        ),
-        'clusteredTasks',
-        'filteredClusteredTasks',
-        {
-          statuses: VALID_STATUSES,
-          includeLocked: false,
-        }
+          ),
+          'clusteredTasks',
+          'filteredClusteredTasks',
+          {
+            statuses: VALID_STATUSES,
+            includeLocked: false,
+          }
+        )
       )
     )
   ), descriptor
diff --git a/src/components/Widgets/TaskBundleWidget/TaskMarkerContent.js b/src/components/Widgets/TaskBundleWidget/TaskMarkerContent.js
index baff5080..4dcbc1f4 100644
--- a/src/components/Widgets/TaskBundleWidget/TaskMarkerContent.js
+++ b/src/components/Widgets/TaskBundleWidget/TaskMarkerContent.js
@@ -10,11 +10,11 @@ import messages from './Messages'
  */
 class TaskMarkerContent extends Component {
   toggleSelection = () => {
-    this.props.toggleTaskSelectionById(this.props.marker.options.taskId)
+    this.props.toggleTaskSelection(this.props.marker.options)
   }
 
   render() {
-    const selected = this.props.selectedTasks.has(this.props.marker.options.taskId)
+    const selected = this.props.isTaskSelected(this.props.marker.options.taskId)
     const statusMessage = messagesByStatus[
       _isFinite(this.props.marker.options.taskStatus) ?
       this.props.marker.options.taskStatus :
diff --git a/src/components/Widgets/TaskCompletionWidget/TaskCompletionWidget.js b/src/components/Widgets/TaskCompletionWidget/TaskCompletionWidget.js
index 830af00d..866fb918 100644
--- a/src/components/Widgets/TaskCompletionWidget/TaskCompletionWidget.js
+++ b/src/components/Widgets/TaskCompletionWidget/TaskCompletionWidget.js
@@ -27,7 +27,7 @@ const descriptor = {
 export default class TaskCompletionWidget extends Component {
   bundleTasks = () => {
     this.props.createTaskBundle(
-      _uniq([this.props.task.id].concat([...this.props.selectedTasks.keys()]))
+      _uniq([this.props.task.id].concat([...this.props.selectedTasks.selected.keys()]))
     )
   }
 
@@ -45,9 +45,9 @@ export default class TaskCompletionWidget extends Component {
     // If we have selected Tasks but no tasks in bundle, a bundle was started
     // but not created with server
     if (!this.props.taskReadOnly &&
-        _get(this.props, 'selectedTasks.size') > 0 && taskCount === 0) {
-      taskCount = this.props.selectedTasks.size
-      if (!this.props.selectedTasks.has(this.props.task.id)) {
+        _get(this.props, 'selectedTasks.selected.size') > 0 && taskCount === 0) {
+      taskCount = this.props.selectedTasks.selected.size
+      if (!this.props.selectedTasks.selected.has(this.props.task.id)) {
         taskCount += 1 // Count the current task if needed
       }
 
diff --git a/src/interactions/Challenge/AsMappableChallenge.js b/src/interactions/Challenge/AsMappableChallenge.js
index fe8b724c..9b8e5376 100644
--- a/src/interactions/Challenge/AsMappableChallenge.js
+++ b/src/interactions/Challenge/AsMappableChallenge.js
@@ -14,11 +14,8 @@ export class AsMappableChallenge {
     if (!_isFinite(this.id)) {
       return null
     }
-
     return basemapLayerSource(this.defaultBasemap,
-                              this.defaultBasemapId,
-                              this.customBasemap,
-                              `challenge_${this.id}`)
+                              this.defaultBasemapId)
   }
 }
 
diff --git a/src/interactions/GeoJSON/AsValidatableGeoJSON.js b/src/interactions/GeoJSON/AsValidatableGeoJSON.js
index b96472d7..db4ecaf4 100644
--- a/src/interactions/GeoJSON/AsValidatableGeoJSON.js
+++ b/src/interactions/GeoJSON/AsValidatableGeoJSON.js
@@ -3,7 +3,10 @@ import _isString from 'lodash/isString'
 import _flatten from 'lodash/flatten'
 import _map from 'lodash/map'
 import _trim from 'lodash/trim'
+import { featureEach } from '@turf/meta'
+import { getGeom } from '@turf/invariant'
 import AsLineReadableFile from '../File/AsLineReadableFile'
+import messages from './Messages'
 
 const RS = String.fromCharCode(0x1E) // RS (record separator) control char
 
@@ -75,18 +78,30 @@ export class AsValidatableGeoJSON {
     let lineNumber = 1
 
     this.geoJSONFile.rewind()
-    this.geoJSONFile.forEach(1, rawLine => {
+    await this.geoJSONFile.forEach(1, rawLine => {
       const line = this.normalizeRFC7464Sequence(_trim(rawLine))
       if (line.length > 0) { // Skip blank lines or pure whitespace
         try {
           const geoJSONObject = JSON.parse(line)
-          const errors = geojsonhint.hint(geoJSONObject)
-          if (errors.length > 0) {
-            // remap line numbers
-            allErrors.push(_map(errors, error => ({
-              line: lineNumber,
-              message: error.message,
-            })))
+          if (geoJSONObject) {
+            try {
+              this.flagUnsupportedGeometries(geoJSONObject)
+            }
+            catch(errorMessage) {
+              allErrors.push({
+                line: lineNumber,
+                message: errorMessage,
+              })
+            }
+
+            const errors = geojsonhint.hint(geoJSONObject)
+            if (errors.length > 0) {
+              // remap line numbers
+              allErrors.push(_map(errors, error => ({
+                line: lineNumber,
+                message: error.message,
+              })))
+            }
           }
         }
         catch(parseError) {
@@ -127,8 +142,35 @@ export class AsValidatableGeoJSON {
       return [{message: `${parseError}`}]
     }
 
+    if (geoJSONObject) {
+      try {
+        this.flagUnsupportedGeometries(geoJSONObject)
+      }
+      catch(errorMessage) {
+        return [{message: errorMessage}]
+      }
+    }
+
     return geojsonhint.hint(geoJSONObject)
   }
+
+  /**
+   * Throw error if unsupported (even if technically legal) geometries are
+   * discovered, as otherwise these will simply result in errors or other
+   * problematic behavior on the backend
+   */
+  flagUnsupportedGeometries(geoJSONObject) {
+    featureEach(geoJSONObject, feature => {
+      const geom = getGeom(feature)
+      if (!geom) {
+        throw messages.noNullGeometry
+      }
+
+      if (geom.type === "Point" && geom.coordinates.length > 2) {
+        throw messages.noZCoordinates
+      }
+    })
+  }
 }
 
 export default geoJSON => new AsValidatableGeoJSON(geoJSON)
diff --git a/src/interactions/GeoJSON/Messages.js b/src/interactions/GeoJSON/Messages.js
new file mode 100644
index 00000000..c4bbc883
--- /dev/null
+++ b/src/interactions/GeoJSON/Messages.js
@@ -0,0 +1,16 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with GeoJSON interactions
+ */
+export default defineMessages({
+  noZCoordinates: {
+    id: "Admin.EditChallenge.geojson.errors.noZCoordinates",
+    defaultMessage: "MapRoulette does not support Z coordinates in Points. Please remove any Z coordinates.",
+  },
+
+  noNullGeometry: {
+    id: "Admin.EditChallenge.geojson.errors.noNullGeometry",
+    defaultMessage: "MapRoulette does not support null geometries. Please remove any features with null geometries.",
+  },
+})
diff --git a/src/interactions/TaskCluster/AsMappableCluster.js b/src/interactions/TaskCluster/AsMappableCluster.js
index 06bb6df4..cbd9ba19 100644
--- a/src/interactions/TaskCluster/AsMappableCluster.js
+++ b/src/interactions/TaskCluster/AsMappableCluster.js
@@ -1,6 +1,7 @@
 import L from 'leaflet'
 import 'leaflet-vectoricon'
 import _isFunction from 'lodash/isFunction'
+import _isFinite from 'lodash/isFinite'
 import _merge from 'lodash/merge'
 import _cloneDeep from 'lodash/cloneDeep'
 import _fromPairs from 'lodash/fromPairs'
@@ -67,7 +68,7 @@ export class AsMappableCluster {
    * Generates a Leaflet Icon object appropriate for the given cluster based on
    * its size, including using a standard marker for a single point
    */
-  leafletMarkerIcon(monochromatic=false, selectedTasks, highlightPrimaryTask) {
+  leafletMarkerIcon(monochromatic=false, selectedTasks, highlightPrimaryTask=false) {
     const count = _isFunction(this.rawData.getChildCount) ?
                   this.rawData.getChildCount() :
                   _get(this.options, 'numberOfPoints', this.numberOfPoints)
@@ -88,13 +89,20 @@ export class AsMappableCluster {
       })
     }
     else {
-      const markerData = _merge(this.rawData, {taskStatus: this.rawData.status,
-                                               taskPriority: this.rawData.priority,
-                                               taskId: this.rawData.id})
+      const markerData = _merge(this.rawData, {
+        taskStatus: this.rawData.status,
+        taskPriority: this.rawData.priority,
+        taskId: this.rawData.id
+      })
+
+      const isSelected = selectedTasks && (
+        (selectedTasks.allSelected && !selectedTasks.deselected.has(markerData.taskId)) ||
+        (!selectedTasks.allSelected && selectedTasks.selected.has(markerData.taskId))
+      )
 
       let icon = _cloneDeep(statusIcons[markerData.taskStatus] || statusIcons[0])
 
-      if (highlightPrimaryTask && highlightPrimaryTask === markerData.taskId) {
+      if (_isFinite(highlightPrimaryTask) && highlightPrimaryTask === markerData.taskId) {
         // Make marker for current task larger
         icon = _cloneDeep(primaryTaskStatusIcons[markerData.taskStatus])
         icon.options.svgHeight = 40
@@ -102,9 +110,10 @@ export class AsMappableCluster {
         icon.options.style.fill = colors.yellow
         icon.options.iconAnchor = [5, 25] // adjust position of marker tip for larger size
       }
-      else if (selectedTasks && selectedTasks.has(markerData.taskId)) {
+      else if (isSelected) {
         icon = _cloneDeep(selectedTaskStatusIcons[markerData.taskStatus])
-        icon.options.style.fill = colors.yellow
+        icon.options.style.stroke = colors.yellow
+        icon.options.style.strokeWidth = 2
       }
       icon.options.taskData = markerData
 
diff --git a/src/interactions/TaskFeature/AsSimpleStyleableFeature.js b/src/interactions/TaskFeature/AsSimpleStyleableFeature.js
index 3dec2606..015f26b2 100644
--- a/src/interactions/TaskFeature/AsSimpleStyleableFeature.js
+++ b/src/interactions/TaskFeature/AsSimpleStyleableFeature.js
@@ -53,63 +53,19 @@ export class AsSimpleStyleableFeature {
    * present on this feature
    */
   styleLeafletLayer(layer) {
-    if (this.conditionalStyles) {
-      this.styleLeafletLayerConditionally(layer, this.conditionalStyles)
-    }
-    else if (this.properties) {
-      this.styleLeafletLayerWithStyles(layer, this.simplestyleFeatureProperties(), false)
-    }
-  }
-
-  /**
-   * Apply the given styles conditionally based on whether this feature matches
-   * the filters associated with each style. All matching styles (if any) are
-   * combined and applied together on top of any normal styling from
-   * simplestyle properties on this feature
-   */
-  styleLeafletLayerConditionally(layer, conditionalStyles) {
-    const filterableFeature = AsFilterableFeature(this)
-    const matchingStyles = _filter(conditionalStyles, style =>
-      filterableFeature.matchesPropertyFilter(style.propertySearch)
-    )
-
-    if (matchingStyles.length > 0) {
-      // flatten all matching styles into a single object
-      const styleObject = _fromPairs(_flatten(_map(matchingStyles, match =>
-        _map(match.styles, style => [style.styleName, style.styleValue])
-      )))
-
-      this.styleLeafletLayerWithStyles(layer, styleObject)
-    }
-    else {
-      this.styleLeafletLayerWithStyles(layer)
-    }
+    this.styleLeafletLayerWithStyles(layer, this.getFinalLayerStyles(layer))
   }
 
   /**
    * Styles the given Leaflet layer using the given simplestyles object of
-   * simplestyle property names and desired values. If
-   * mergeWithFeatureProperties is true (the default) then all simplestyle
-   * properties specified on the feature will also be used, but overriden by
-   * given styles where duplicated
+   * simplestyle property names and desired values
    */
-  styleLeafletLayerWithStyles(layer, simplestyles, mergeWithFeatureProperties=true) {
-    const styles =
-      mergeWithFeatureProperties ?
-      _merge({}, this.simplestyleFeatureProperties(), simplestyles) :
-      simplestyles
-
+  styleLeafletLayerWithStyles(layer, simplestyles) {
     if (getType(layer.feature) === 'Point') {
-      const supportedStyles =
-        _intersection(_keys(styles), supportedSimplestylePointProperties)
-
-      this.styleLeafletMarkerLayer(layer, _pick(styles, supportedStyles))
+      this.styleLeafletMarkerLayer(layer, simplestyles)
     }
     else {
-      const supportedStyles =
-        _intersection(_keys(styles), supportedSimplestyleLineProperties)
-
-      this.styleLeafletPathLayer(layer, _pick(styles, supportedStyles))
+      this.styleLeafletPathLayer(layer, simplestyles)
     }
   }
 
@@ -195,6 +151,67 @@ export class AsSimpleStyleableFeature {
       null
     ))))
   }
+
+  /**
+   * Generates an object containing the final determined styles for the given
+   * layer (taking the layer type into account), or for this feature more
+   * broadly if no layer is specified
+   */
+  getFinalLayerStyles(layer) {
+    if (this.conditionalStyles) {
+      return this.getConditionalStyles(layer, this.conditionalStyles)
+    }
+    else if (this.properties) {
+      return this.getStyles(layer, this.simplestyleFeatureProperties(), false)
+    }
+    else {
+      return {}
+    }
+  }
+
+  /**
+   * Apply the given styles conditionally based on whether this feature matches
+   * the filters associated with each style. All matching styles (if any) are
+   * combined and applied together on top of any normal styling from
+   * simplestyle properties on this feature
+   */
+  getConditionalStyles(layer, conditionalStyles) {
+    const filterableFeature = AsFilterableFeature(this)
+    const matchingStyles = _filter(conditionalStyles, style =>
+      filterableFeature.matchesPropertyFilter(style.propertySearch)
+    )
+
+    if (matchingStyles.length > 0) {
+      // flatten all matching styles into a single object
+      const styleObject = _fromPairs(_flatten(_map(matchingStyles, match =>
+        _map(match.styles, style => [style.styleName, style.styleValue])
+      )))
+
+      return this.getStyles(layer, styleObject)
+    }
+    else {
+      return this.getStyles(layer)
+    }
+  }
+
+  getStyles(layer, simplestyles, mergeWithFeatureProperties=true) {
+    const styles =
+      mergeWithFeatureProperties ?
+      _merge({}, this.simplestyleFeatureProperties(), simplestyles) :
+      simplestyles
+
+    // If we have a layer, only return styles applicable to that layer's type
+    if (!layer) {
+      return styles
+    }
+
+    const supportedStyles =
+      getType(layer.feature) === 'Point' ?
+      _intersection(_keys(styles), supportedSimplestylePointProperties) :
+      _intersection(_keys(styles), supportedSimplestyleLineProperties)
+
+    return _pick(styles, supportedStyles)
+  }
 }
 
 export default (feature, conditionalStyles) =>
diff --git a/src/interactions/User/AsEditableUser.js b/src/interactions/User/AsEditableUser.js
index 638f8553..78a2f95e 100644
--- a/src/interactions/User/AsEditableUser.js
+++ b/src/interactions/User/AsEditableUser.js
@@ -1,5 +1,6 @@
 import _isFinite from 'lodash/isFinite'
 import _isString from 'lodash/isString'
+import _find from 'lodash/find'
 import { ChallengeBasemap }
        from '../../services/Challenge/ChallengeBasemap/ChallengeBasemap'
 
@@ -21,14 +22,23 @@ export class AsEditableUser {
    * identifier if appropriate.
    *
    */
-  normalizeDefaultBasemap() {
+  normalizeDefaultBasemap(layerSources, customBasemaps) {
     if (_isFinite(Number(this.defaultBasemap))) {
       this.defaultBasemapId = ''
       this.defaultBasemap = Number(this.defaultBasemap)
     }
     else if (_isString(this.defaultBasemap) && this.defaultBasemap.length > 0) {
-      this.defaultBasemapId = this.defaultBasemap
-      this.defaultBasemap = ChallengeBasemap.identified
+      // Check to make sure our defaultBasemap is in our valid list of basemaps
+      // If not found then set the default basemap to none.
+      if (!_find(customBasemaps, (basemap) => basemap.name === this.defaultBasemap) &&
+          !_find(layerSources, (basemap) => basemap.id === this.defaultBasemap)) {
+        this.defaultBasemapId = ''
+        this.defaultBasemap = ChallengeBasemap.none
+      }
+      else {
+        this.defaultBasemapId = this.defaultBasemap
+        this.defaultBasemap = ChallengeBasemap.identified
+      }
     }
     else {
       this.defaultBasemapId = ''
diff --git a/src/interactions/User/AsMappingUser.js b/src/interactions/User/AsMappingUser.js
index 8cf49193..4bb95ada 100644
--- a/src/interactions/User/AsMappingUser.js
+++ b/src/interactions/User/AsMappingUser.js
@@ -1,6 +1,10 @@
 import _isFinite from 'lodash/isFinite'
 import _isObject from 'lodash/isObject'
-import { basemapLayerSource }
+import _clone from 'lodash/clone'
+import _map from 'lodash/map'
+import _isEmpty from 'lodash/isEmpty'
+import _find from 'lodash/find'
+import { basemapLayerSource, LayerSources }
        from '../../services/VisibleLayer/LayerSources'
 
 /**
@@ -11,15 +15,31 @@ export class AsMappingUser {
     Object.assign(this, user)
   }
 
+  allLayerSources() {
+    const customBasemaps = this.settings ?
+      _map(this.settings.customBasemaps,
+        basemap => basemapLayerSource(basemap, basemap.name)
+      ) : []
+
+    return _clone(LayerSources).concat(customBasemaps)
+  }
+
   defaultLayerSource() {
     if (!_isFinite(this.id) || !_isObject(this.settings)) {
       return null
     }
 
+    // Try to find a custom basemap if the user has one selected.
+    if (!_isEmpty(this.settings.defaultBasemapId)) {
+      return this.findLayerSource(this.settings.defaultBasemapId)
+    }
+
     return basemapLayerSource(this.settings.defaultBasemap,
-                              this.settings.defaultBasemapId,
-                              this.settings.customBasemap,
-                              `user_${this.id}`)
+                              this.settings.defaultBasemapId)
+  }
+
+  findLayerSource(layerId) {
+    return _find(this.allLayerSources(), (source) => source.id === layerId)
   }
 }
 
diff --git a/src/lang/en-US.json b/src/lang/en-US.json
index bb4b7362..79118282 100644
--- a/src/lang/en-US.json
+++ b/src/lang/en-US.json
@@ -130,6 +130,7 @@
   "Admin.EditChallenge.form.limitTags.description": "Allow other tags during task completion?",
   "Admin.EditChallenge.form.localGeoJson.description": "Please upload the local GeoJSON file from your computer",
   "Admin.EditChallenge.form.localGeoJson.label": "I want to upload a GeoJSON file",
+  "Admin.EditChallenge.form.localGeoJson.readOnly": "If you need to upload fresh GeoJSON, choose Rebuild Tasks when managing your challenge.",
   "Admin.EditChallenge.form.lowPriorityRules.label": "Low Priority Rules",
   "Admin.EditChallenge.form.maxZoom.description": "The maximum allowed zoom level for your challenge. This should be set to a level that allows the user to sufficiently zoom in to work on the tasks while keeping them from zooming in to a level that isn’t useful or exceeds the available resolution of the map/imagery in the geographic region.",
   "Admin.EditChallenge.form.maxZoom.label": "Maximum Zoom Level",
@@ -143,6 +144,7 @@
   "Admin.EditChallenge.form.overpassQL.description": "Please see the [wiki](https://github.com/osmlab/maproulette3/wiki/Using-Overpass-to-create-Challenges) for important details and common pitfalls when creating challenges using Overpass queries.",
   "Admin.EditChallenge.form.overpassQL.label": "I want to provide an Overpass query",
   "Admin.EditChallenge.form.overpassQL.placeholder": "Enter Overpass API query here...",
+  "Admin.EditChallenge.form.overpassQL.readOnly": "Overpass queries cannot be edited, but you can choose Rebuild Tasks when managing your challenge to re-execute the query and freshen up your tasks.",
   "Admin.EditChallenge.form.preferredReviewTags.description": "You can optionally provide a list of preferred tags that you want the reviewer to use when reviewing a task.",
   "Admin.EditChallenge.form.preferredReviewTags.label": "Preferred MR Review Tags",
   "Admin.EditChallenge.form.preferredTags.description": "You can optionally provide a list of preferred tags that you want the user to use when completing a task.",
@@ -150,6 +152,7 @@
   "Admin.EditChallenge.form.remoteGeoJson.description": "Remote URL location from which to retrieve the GeoJSON",
   "Admin.EditChallenge.form.remoteGeoJson.label": "I have a URL to the GeoJSON data",
   "Admin.EditChallenge.form.remoteGeoJson.placeholder": "https://www.example.com/geojson.json",
+  "Admin.EditChallenge.form.remoteGeoJson.readOnly": "Remote URLs cannot be edited, but you can choose Rebuild Tasks when managing your challenge to re-download updated remote GeoJSON and freshen up your tasks.",
   "Admin.EditChallenge.form.requiresLocal.description": "Tasks require local or on-the-ground knowledge to complete. Note: challenge will not appear in the Find Challenges list.",
   "Admin.EditChallenge.form.requiresLocal.label": "Requires Local Knowledge",
   "Admin.EditChallenge.form.source.label": "Location of your Task Data",
@@ -193,6 +196,8 @@
   "Admin.EditChallenge.form.updateTasks.label": "Remove Stale Tasks",
   "Admin.EditChallenge.form.visible.description": "Allow your challenge to be easily discoverable by other users via Find Challenges (subject to project discoverability). Note that all challenges are considered public and, even when Discoverable is off, users can still view your challenge if they have a direct link to it.",
   "Admin.EditChallenge.form.visible.label": "Discoverable",
+  "Admin.EditChallenge.geojson.errors.noNullGeometry": "MapRoulette does not support null geometries. Please remove any features with null geometries.",
+  "Admin.EditChallenge.geojson.errors.noZCoordinates": "MapRoulette does not support Z coordinates in Points. Please remove any Z coordinates.",
   "Admin.EditChallenge.lineNumber": "Line {line, number}: ",
   "Admin.EditChallenge.new.header": "New Challenge",
   "Admin.EditChallenge.overpass.errors.noTurboShortcuts": "Overpass Turbo shortcuts are not supported. If you wish to use them, please visit Overpass Turbo and test your query, then choose Export -> Query -> Standalone -> Copy and then paste that here.",
@@ -406,6 +411,8 @@
   "Challenge.location.nearMe": "Near Me",
   "Challenge.location.withinMapBounds": "Within Map Bounds",
   "Challenge.management.controls.manage.label": "Manage",
+  "Challenge.result.difficulty.label": "Difficulty",
+  "Challenge.results.controls.browse.label": "More Details",
   "Challenge.results.heading": "Challenges",
   "Challenge.results.noResults": "No Results",
   "Challenge.signIn.label": "Sign in to get started",
@@ -721,7 +728,7 @@
   "Errors.reviewTask.fetchFailure": "Unable to fetch review needed tasks",
   "Errors.reviewTask.notClaimedByYou": "Unable to cancel review.",
   "Errors.task.alreadyLocked": "Task has already been locked by someone else.",
-  "Errors.task.bundleFailure": "Unable to bundling tasks together",
+  "Errors.task.bundleFailure": "Unable to bundle tasks together",
   "Errors.task.cooperativeFailure": "Failed to load cooperative task{details}",
   "Errors.task.deleteFailure": "Unable to delete task.",
   "Errors.task.doesNotExist": "That task does not exist.",
@@ -947,14 +954,19 @@
   "Profile.apiKey.controls.copy.label": "Copy",
   "Profile.apiKey.controls.reset.label": "Reset",
   "Profile.apiKey.header": "API Key",
+  "Profile.form.addCustomBasemap.label": "Add Custom Basemap",
   "Profile.form.allowFollowing.description": "If no, users will not be able to follow your MapRoulette activity.",
   "Profile.form.allowFollowing.label": "Allow Following",
   "Profile.form.customBasemap.description": "Insert a custom base map here. E.g. `https://'{s}'.tile.openstreetmap.org/'{z}'/'{x}'/'{y}'.png`",
-  "Profile.form.customBasemap.label": "Custom Basemap",
+  "Profile.form.customBasemap.name.label": "Name",
+  "Profile.form.customBasemap.overlay.label": "is overlay?",
+  "Profile.form.customBasemap.url.label": "URL",
+  "Profile.form.customBasemaps.label": "Custom Basemaps",
   "Profile.form.defaultBasemap.description": "Select the default basemap to display on the map. Only a default challenge basemap will override the option selected here.",
   "Profile.form.defaultBasemap.label": "Default Basemap",
   "Profile.form.defaultEditor.description": "Select the default editor that you want to use when fixing tasks. By selecting this option you will be able to skip the editor selection dialog after clicking on edit in a task.",
   "Profile.form.defaultEditor.label": "Default Editor",
+  "Profile.form.deleteCustomBasemap.label": "Delete",
   "Profile.form.email.description": "If you request emails in your Notification Subscriptions, they will be sent here.\n\nDecide which MapRoulette notifications you would like to receive, along with whether you would like to be sent an email informing you of the notification (either immediately or as a daily digest)",
   "Profile.form.email.label": "Email address",
   "Profile.form.isReviewer.description": "Volunteer to review tasks for which a review has been requested",
@@ -970,6 +982,7 @@
   "Profile.form.notification.label": "Notification",
   "Profile.form.notificationSubscriptions.description": "Decide which MapRoulette notifications you would like to receive, along with whether you would like to be sent an email informing you of the notification (either immediately or as a daily digest)",
   "Profile.form.notificationSubscriptions.label": "Notification Subscriptions",
+  "Profile.form.uniqueCustomBasemap.error": "Custom Basemap names must be unique",
   "Profile.form.yes.label": "Yes",
   "Profile.noUser": "User not found or you are unauthorized to view this user.",
   "Profile.page.title": "User Settings",
@@ -1145,6 +1158,8 @@
   "Task.controls.revised.tooltip": "Revision Complete",
   "Task.controls.skip.label": "Skip",
   "Task.controls.skip.tooltip": "Skip Task",
+  "Task.controls.step1.changeStatusOptions": "Change Status",
+  "Task.controls.step1.otherOptions": "Other",
   "Task.controls.step1.revisionNeeded": "This task needs revision. Be sure to check comments for any details.",
   "Task.controls.tooHard.label": "Too hard / Can’t see",
   "Task.controls.tooHard.tooltip": "Too hard / Can’t see",
@@ -1455,6 +1470,7 @@
   "Widgets.TaskMoreOptionsWidget.title": "More Options",
   "Widgets.TaskNearbyMap.currentTaskTooltip": "Current Task",
   "Widgets.TaskNearbyMap.noTasksAvailable.label": "No nearby tasks are available.",
+  "Widgets.TaskNearbyMap.tooltip.loadMoreTasks.control": "Load More Tasks",
   "Widgets.TaskNearbyMap.tooltip.priorityLabel": "Priority: ",
   "Widgets.TaskNearbyMap.tooltip.statusLabel": "Status: ",
   "Widgets.TaskPropertiesWidget.label": "Task Properties",
diff --git a/src/pages/Profile/Messages.js b/src/pages/Profile/Messages.js
index 31d67cfa..79d03833 100644
--- a/src/pages/Profile/Messages.js
+++ b/src/pages/Profile/Messages.js
@@ -44,9 +44,9 @@ export default defineMessages({
     defaultMessage: "Select the default basemap to display on the map. Only a default challenge basemap will override the option selected here.",
   },
 
-  customBasemapLabel: {
-    id: "Profile.form.customBasemap.label",
-    defaultMessage: "Custom Basemap",
+  customBasemapsLabel: {
+    id: "Profile.form.customBasemaps.label",
+    defaultMessage: "Custom Basemaps",
   },
 
   customBasemapDescription: {
@@ -54,6 +54,36 @@ export default defineMessages({
     defaultMessage: "Insert a custom base map here. E.g. `https://'{s}'.tile.openstreetmap.org/'{z}'/'{x}'/'{y}'.png`",
   },
 
+  addCustomBasemapLabel: {
+    id: "Profile.form.addCustomBasemap.label",
+    defaultMessage: "Add Custom Basemap",
+  },
+
+  deleteCustomBasemapLabel: {
+    id: "Profile.form.deleteCustomBasemap.label",
+    defaultMessage: "Delete",
+  },
+
+  customBasemapNameLabel: {
+    id: "Profile.form.customBasemap.name.label",
+    defaultMessage: "Name",
+  },
+
+  customBasemapURLLabel: {
+    id: "Profile.form.customBasemap.url.label",
+    defaultMessage: "URL",
+  },
+
+  customBasemapOverlayLabel: {
+    id: "Profile.form.customBasemap.overlay.label",
+    defaultMessage: "is overlay?",
+  },
+
+  uniqueCustomBasemapError: {
+    id: "Profile.form.uniqueCustomBasemap.error",
+    defaultMessage: "Custom Basemap names must be unique",
+  },
+
   localeLabel: {
     id: "Profile.form.locale.label",
     defaultMessage: "Locale",
diff --git a/src/pages/Profile/UserSettings/UserSettings.js b/src/pages/Profile/UserSettings/UserSettings.js
index 77a2e66a..5ec19b07 100644
--- a/src/pages/Profile/UserSettings/UserSettings.js
+++ b/src/pages/Profile/UserSettings/UserSettings.js
@@ -12,15 +12,24 @@ import _isUndefined from 'lodash/isUndefined'
 import _isEmpty from 'lodash/isEmpty'
 import _isFinite from 'lodash/isFinite'
 import _debounce from 'lodash/debounce'
+import _remove from 'lodash/remove'
+import _find from 'lodash/find'
+import _cloneDeep from 'lodash/cloneDeep'
+import _uniq from 'lodash/uniq'
+import _countBy from 'lodash/countBy'
+import _findLastIndex from 'lodash/findLastIndex'
+import _trim from 'lodash/trim'
 import { basemapLayerSources }
        from '../../../services/Challenge/ChallengeBasemap/ChallengeBasemap'
+import { LayerSources }
+      from '../../../services/VisibleLayer/LayerSources'
 import { NotificationSubscriptionType, keysByNotificationType }
        from '../../../services/Notification/NotificationType/NotificationType'
 import AsEditableUser from '../../../interactions/User/AsEditableUser'
 import WithStatus from '../../../components/HOCs/WithStatus/WithStatus'
 import BusySpinner from '../../../components/BusySpinner/BusySpinner'
 import SvgSymbol from '../../../components/SvgSymbol/SvgSymbol'
-import { CustomSelectWidget, NoFieldsetObjectFieldTemplate }
+import { CustomSelectWidget, NoFieldsetObjectFieldTemplate, CustomArrayFieldTemplate }
        from '../../../components/Bulma/RJSFFormFieldAdapter/RJSFFormFieldAdapter'
 import { jsSchema as settingsJsSchema, uiSchema as settingsUiSchema }
        from './UserSettingsSchema'
@@ -38,14 +47,58 @@ class UserSettings extends Component {
 
   /** Save the latest user settings modified by the user */
   saveUserSettings = _debounce((settings) => {
+    // We cannot save the user settings if the basemap names are not unique.
+    // Otherwise, this will mess up our attempts to match the server generated
+    // id with the matching custom basemap with that name.
+    if (!this.areBasemapNamesUnique(settings.customBasemaps)) {
+      return
+    }
+
     this.setState({isSaving: true, saveComplete: false})
 
-    const editableUser = AsEditableUser(settings)
-    editableUser.normalizeDefaultBasemap()
+    const editableUser = AsEditableUser(_cloneDeep(settings))
+
+    // Massage customBasemaps json:
+    // 1.don't send if name or url has not been completed yet
+    // 2. insert id: -1 if it's a new customBasemap
+    _remove(editableUser.customBasemaps,
+      (data) => (_isEmpty(_trim(data.name)) || _isEmpty(_trim(data.url))))
+    editableUser.customBasemaps.forEach( (data) => {
+      if (!data.id) {
+        data.id = -1
+      }
+    })
+
+    editableUser.normalizeDefaultBasemap(LayerSources, editableUser.customBasemaps)
 
     this.props.updateUserSettings(
       this.props.user.id, editableUser
-    ).then(() => this.setState({isSaving: false, saveComplete: true}))
+    ).then((results) => {
+      // Make sure the correct defaultBasemapId is set on the form.
+      // If a custom basemap was removed that was also set as the default,
+      // then this would be set back to None by the normalizeDefaultBasemap().
+      const updatedUser = _get(results, 'entities.users', [])[this.props.user.id]
+      const settingsFormData = _cloneDeep(this.state.settingsFormData)
+      settingsFormData.defaultBasemap = updatedUser.settings.defaultBasemapId || "-1"
+
+      // If we have new customBasemaps data in our state then we need to update any
+      // matching new mappings with the server generated id.
+      if (_get(updatedUser, 'settings.customBasemaps')) {
+        const serverBasemaps = _get(updatedUser, 'settings.customBasemaps')
+        _each(settingsFormData.customBasemaps, basemap => {
+          if (!basemap.id && !_isEmpty(basemap.url) && !_isEmpty(basemap.name) &&
+              _find(serverBasemaps, m => m.name === basemap.name)) {
+            basemap.id = _find(serverBasemaps, m => m.name === basemap.name).id
+          }
+        })
+      }
+
+       // Save the customBasemaps frmo the server in state so we we can match
+       // the newly generated
+      this.setState({isSaving: false,
+        saveComplete: true,
+        settingsFormData: settingsFormData})
+    })
   }, 750)
 
   /** Save the latest notification settings modified by the user */
@@ -66,10 +119,35 @@ class UserSettings extends Component {
     this.saveUserSettings(formData)
   }
 
+  areBasemapNamesUnique = (basemaps) => {
+    return _uniq(_map(basemaps, 'name')).length === basemaps.length
+  }
+
+  validate = (formData, errors) => {
+    // Validates that all custom basemap names are unique.
+    const basemapNames = _countBy(formData.customBasemaps, bm => bm.name)
+    _each(basemapNames, (count, name) => {
+      if (count > 1) {
+        const badIndex = _findLastIndex(formData.customBasemaps, bm => bm.name === name)
+        if (errors.customBasemaps[badIndex]) {
+          errors.customBasemaps[badIndex].addError(
+            this.props.intl.formatMessage(messages.uniqueCustomBasemapError)
+          )
+        }
+      }
+    })
+
+    return errors
+  }
+
   notificationsChangeHandler = (userSettings, {formData}) => {
     // The user's email address comes in from the notifications data even
     // though its technically a user setting
-    this.settingsChangeHandler({formData: _merge(userSettings, _pick(formData, 'email'))})
+    const toUpdateSettings = _merge({}, userSettings, _pick(formData, 'email'))
+    if (this.state.settingsFormData.customBasemaps) {
+      toUpdateSettings.customBasemaps = this.state.settingsFormData.customBasemaps
+    }
+    this.settingsChangeHandler({formData: toUpdateSettings})
 
     this.setState({
       notificationsFormData: formData,
@@ -130,7 +208,7 @@ class UserSettings extends Component {
         />
     }
 
-    const userSettings = _merge(this.props.user.settings, this.state.settingsFormData)
+    const userSettings = _merge({}, this.props.user.settings, this.state.settingsFormData)
 
     // The server uses two fields to represent the default basemap: a legacy
     // numeric identifier and a new optional string identifier for layers from
@@ -154,6 +232,13 @@ class UserSettings extends Component {
       }
     }
 
+    // Make sure the userSettings picks up the customBasemaps changes.
+    // If a customBasemap is removed then then merge above will still include it
+    // in the list.
+    if (this.state.settingsFormData.customBasemaps) {
+      userSettings.customBasemaps = this.state.settingsFormData.customBasemaps
+    }
+
     const notificationSettings = _merge(
       this.prepareNotificationsDataForForm(userSettings, {
         notificationSubscriptions: this.props.user.notificationSubscriptions,
@@ -178,9 +263,11 @@ class UserSettings extends Component {
           liveValidate
           noHtml5Validate
           showErrorList={false}
+          validate={this.validate}
           formData={userSettings}
           onChange={this.settingsChangeHandler}
           ObjectFieldTemplate={NoFieldsetObjectFieldTemplate}
+          ArrayFieldTemplate={CustomArrayFieldTemplate}
         >
           <div className="form-controls" />
         </Form>
diff --git a/src/pages/Profile/UserSettings/UserSettingsSchema.js b/src/pages/Profile/UserSettings/UserSettingsSchema.js
index acaadbb8..49f25bfa 100644
--- a/src/pages/Profile/UserSettings/UserSettingsSchema.js
+++ b/src/pages/Profile/UserSettings/UserSettingsSchema.js
@@ -1,7 +1,6 @@
 import React from 'react'
 import _map from 'lodash/map'
 import _values from 'lodash/values'
-import _without from 'lodash/without'
 import _filter from 'lodash/filter'
 import AsManager from '../../../interactions/User/AsManager'
 import { Locale, localeLabels, defaultLocale }
@@ -30,16 +29,41 @@ export const jsSchema = (intl, user, editor) => {
   const localizedEditorLabels = editorLabels(intl)
   const localizedBasemapLabels = basemapLayerLabels(intl)
 
+  const customBasemaps =
+    _filter(_map(user.settings.customBasemaps, basemap =>
+      ({id: basemap.name, name: basemap.name, overlay: basemap.overlay})),
+      basemap => !basemap.overlay
+    )
+
   const defaultBasemapChoices = [
     { id: ChallengeBasemap.none.toString(), name: localizedBasemapLabels.none }
   ].concat(_map(_filter(LayerSources, source => !source.overlay),
-                source => ({id: source.id, name: source.name}))).concat([
-    { id: ChallengeBasemap.custom.toString(), name: localizedBasemapLabels.custom }
-  ])
+                source => ({id: source.id, name: source.name})))
 
   const schemaFields = {
     "$schema": "http://json-schema.org/draft-07/schema#",
     type: "object",
+    definitions: {
+      customBasemapRule: {
+        type: "object",
+        properties: {
+          name: {
+            title: intl.formatMessage(messages.customBasemapNameLabel),
+            type: "string"
+          },
+          url: {
+            title: intl.formatMessage(messages.customBasemapURLLabel),
+            type: "string"
+          },
+          overlay: {
+            title: intl.formatMessage(messages.customBasemapOverlayLabel),
+            type: "boolean",
+            default: false,
+          },
+        },
+        required: ["name", "url"],
+      },
+    },
     properties: {
       defaultEditor: {
         title: intl.formatMessage(messages.defaultEditorLabel),
@@ -58,10 +82,15 @@ export const jsSchema = (intl, user, editor) => {
       defaultBasemap: {
         title: intl.formatMessage(messages.defaultBasemapLabel),
         type: "string",
-        enum: _map(defaultBasemapChoices, 'id'),
-        enumNames: _map(defaultBasemapChoices, 'name'),
+        enum: _map(defaultBasemapChoices.concat(customBasemaps), 'id'),
+        enumNames: _map(defaultBasemapChoices.concat(customBasemaps), 'name'),
         default: ChallengeBasemap.none.toString(),
       },
+      customBasemaps: {
+        title: intl.formatMessage(messages.customBasemapsLabel),
+        type: "array",
+        items: { "$ref": "#/definitions/customBasemapRule" }
+      },
       leaderboardOptOut: {
         title: intl.formatMessage(messages.leaderboardOptOutLabel),
         type: "boolean",
@@ -78,31 +107,6 @@ export const jsSchema = (intl, user, editor) => {
         default: false,
       },
     },
-    dependencies: { // Only show customBasemap if defaultBasemap set to Custom
-      defaultBasemap: {
-        oneOf: [
-          {
-            properties: {
-              defaultBasemap: {
-                enum: _without(_map(defaultBasemapChoices, 'id'), ChallengeBasemap.custom.toString()),
-              }
-            }
-          },
-          {
-            properties: {
-              defaultBasemap: {
-                enum: [ChallengeBasemap.custom.toString()],
-              },
-              customBasemap: {
-                title: intl.formatMessage(messages.customBasemapLabel),
-                type: "string",
-              },
-            },
-            required: ['customBasemap']
-          }
-        ]
-      }
-    }
   }
 
   // Show 'needsReview' option if value is not REVIEW_MANDATORY or to superusers
@@ -149,9 +153,15 @@ export const uiSchema = (intl, user, editor) => {
       "ui:widget": "select",
       "ui:help": intl.formatMessage(messages.defaultBasemapDescription),
     },
-    customBasemap: {
+    customBasemaps: {
+      "ui:addLabel": intl.formatMessage(messages.addCustomBasemapLabel),
+      "ui:deleteLabel": intl.formatMessage(messages.deleteCustomBasemapLabel),
       "ui:emptyValue": "",
       "ui:help": <MarkdownContent markdown={intl.formatMessage(messages.customBasemapDescription)} />,
+      items: {
+        "ui:options": { inline: false },
+        "classNames": ["custom-basemaps-item"]
+      },
     },
     locale: {
       "ui:widget": "select",
@@ -174,7 +184,8 @@ export const uiSchema = (intl, user, editor) => {
       "ui:help": intl.formatMessage(messages.allowFollowingDescription),
     },
     "ui:order": [
-      "defaultEditor", "locale", "defaultBasemap", "leaderboardOptOut", "customBasemap", "isReviewer","allowFollowing"
+      "locale", "allowFollowing", "defaultEditor", "leaderboardOptOut",
+      "defaultBasemap", "isReviewer", "customBasemaps"
     ],
   }
 
diff --git a/src/pages/Review/Metrics/ReviewStatusMetrics.js b/src/pages/Review/Metrics/ReviewStatusMetrics.js
index 73432f5e..8e130333 100644
--- a/src/pages/Review/Metrics/ReviewStatusMetrics.js
+++ b/src/pages/Review/Metrics/ReviewStatusMetrics.js
@@ -10,6 +10,7 @@ import { TaskPriority, keysByPriority, taskPriorityLabels }
 import { TaskStatus, keysByStatus, statusLabels }
       from '../../../services/Task/TaskStatus/TaskStatus'
 import SvgSymbol from '../../../components/SvgSymbol/SvgSymbol'
+import BusySpinner from '../../../components/BusySpinner/BusySpinner'
 
 
 /**
@@ -92,6 +93,8 @@ export default class ReviewStatusMetrics extends Component {
     )
   }
   render() {
+    if (this.props.loading) return <BusySpinner />
+
     const metrics = this.props.reviewMetrics
     const reviewMetricsByPriority = this.props.reviewMetricsByPriority
     const reviewMetricsByTaskStatus = this.props.reviewMetricsByTaskStatus
diff --git a/src/services/Error/Messages.js b/src/services/Error/Messages.js
index 4fa5ef3f..dfbb2fad 100644
--- a/src/services/Error/Messages.js
+++ b/src/services/Error/Messages.js
@@ -80,7 +80,7 @@ export default defineMessages({
   },
   taskBundleFailure: {
     id: 'Errors.task.bundleFailure',
-    defaultMessage: "Unable to bundling tasks together",
+    defaultMessage: "Unable to bundle tasks together",
   },
   taskCooperativeFailure: {
     id: 'Errors.task.cooperativeFailure',
diff --git a/src/services/Search/Search.js b/src/services/Search/Search.js
index 6633b4c0..3fd81f90 100644
--- a/src/services/Search/Search.js
+++ b/src/services/Search/Search.js
@@ -76,7 +76,8 @@ export const PARAMS_MAP = {
   reviewStatus: 'trStatus',
   id: 'tid',
   difficulty: 'cd',
-  tags: 'tt'
+  tags: 'tt',
+  excludeTasks: 'tExcl',
 }
 
 
@@ -132,7 +133,7 @@ export const parseQueryString = function(rawQueryText) {
  */
 export const generateSearchParametersString = (filters, boundingBox, savedChallengesOnly,
                                                excludeOtherReviewers, queryString,
-                                               invertFields = {}) => {
+                                               invertFields = {}, excludeTasks) => {
   const searchParameters = {}
   const invf = []
 
@@ -286,7 +287,12 @@ export const generateSearchParametersString = (filters, boundingBox, savedChalle
     searchParameters[PARAMS_MAP.tags] = filters.tags
   }
 
+  if (excludeTasks && excludeTasks.length > 0) {
+    searchParameters[PARAMS_MAP.excludeTasks] = excludeTasks.join(',')
+  }
+
   searchParameters.invf = invf.join(',')
+
   return searchParameters
 }
 
diff --git a/src/services/ShortCodes/Handlers/OSMElementHandler.js b/src/services/ShortCodes/Handlers/OSMElementHandler.js
index 7b91112c..b7ddf08e 100644
--- a/src/services/ShortCodes/Handlers/OSMElementHandler.js
+++ b/src/services/ShortCodes/Handlers/OSMElementHandler.js
@@ -28,7 +28,8 @@ const OSMElementHandler = {
   },
 
   handlesShortCode(shortCode) {
-    return new RegExp(this.osmElementRegex).test(shortCode)
+    // Add opening short-code bracket to test to help prevent false positives
+    return new RegExp("\\[" + this.osmElementRegex).test(shortCode)
   },
 
   expandShortCode(shortCode) {
diff --git a/src/services/Task/Task.js b/src/services/Task/Task.js
index 928090d2..06b48310 100644
--- a/src/services/Task/Task.js
+++ b/src/services/Task/Task.js
@@ -324,7 +324,7 @@ export const bulkUpdateTasks = function(updatedTasks, skipConversion=false) {
 /**
  * Bulk update task status on tasks that match the given criteria.
  */
-export const bulkTaskStatusChange = function(newStatus, challengeId, criteria) {
+export const bulkTaskStatusChange = function(newStatus, challengeId, criteria, excludeTaskIds) {
   return function(dispatch) {
     const filters = _get(criteria, 'filters', {})
     const searchParameters = generateSearchParametersString(filters,
@@ -332,7 +332,8 @@ export const bulkTaskStatusChange = function(newStatus, challengeId, criteria) {
                                                             _get(criteria, 'savedChallengesOnly'),
                                                             null,
                                                             criteria.searchQuery,
-                                                            _get(criteria, 'invertFields'))
+                                                            _get(criteria, 'invertFields'),
+                                                            excludeTaskIds)
     searchParameters.cid = challengeId
 
     return new Endpoint(
diff --git a/src/services/Task/TaskReview/TaskReview.js b/src/services/Task/TaskReview/TaskReview.js
index bf4584a2..2013e113 100644
--- a/src/services/Task/TaskReview/TaskReview.js
+++ b/src/services/Task/TaskReview/TaskReview.js
@@ -356,7 +356,7 @@ export const cancelReviewClaim = function(taskId) {
   }
 }
 
-export const removeReviewRequest = function(challengeId, taskIds, criteria = null) {
+export const removeReviewRequest = function(challengeId, taskIds, criteria, excludeTaskIds) {
   return function(dispatch) {
     const filters = _get(criteria, 'filters', {})
     const searchParameters = !criteria ? {} :
@@ -365,9 +365,12 @@ export const removeReviewRequest = function(challengeId, taskIds, criteria = nul
                                      null,
                                      null,
                                      criteria.searchQuery,
-                                     criteria.invertFields)
+                                     criteria.invertFields,
+                                     excludeTaskIds)
     searchParameters.cid = challengeId
-    searchParameters.ids = taskIds ? taskIds.join(',') : null
+    if (taskIds) {
+      searchParameters.ids = taskIds.join(',')
+    }
 
     return new Endpoint(
       api.tasks.removeReviewRequest, {
@@ -391,18 +394,18 @@ export const removeReviewRequest = function(challengeId, taskIds, criteria = nul
 /**
  *
  */
-export const completeReview = function(taskId, taskReviewStatus, comment, tags) {
+export const completeReview = function(taskId, taskReviewStatus, comment, tags, newTaskStatus) {
   return function(dispatch) {
-    return updateTaskReviewStatus(dispatch, taskId, taskReviewStatus, comment, tags)
+    return updateTaskReviewStatus(dispatch, taskId, taskReviewStatus, comment, tags, newTaskStatus)
   }
 }
 
-export const completeBundleReview = function(bundleId, taskReviewStatus, comment, tags) {
+export const completeBundleReview = function(bundleId, taskReviewStatus, comment, tags, newTaskStatus) {
   return function(dispatch) {
     return new Endpoint(api.tasks.bundled.updateReviewStatus, {
       schema: taskBundleSchema(),
       variables: {bundleId, status: taskReviewStatus},
-      params:{comment, tags},
+      params:{comment, tags, newTaskStatus},
     }).execute().catch(error => {
       if (isSecurityError(error)) {
         dispatch(ensureUserLoggedIn()).then(() =>
@@ -452,7 +455,7 @@ export const fetchReviewChallenges = function(reviewTasksType,
   }
 }
 
-const updateTaskReviewStatus = function(dispatch, taskId, newStatus, comment, tags) {
+const updateTaskReviewStatus = function(dispatch, taskId, newStatus, comment, tags, newTaskStatus) {
   // Optimistically assume request will succeed. The store will be updated
   // with fresh task data from the server if the save encounters an error.
   dispatch(receiveTasks({
@@ -468,7 +471,7 @@ const updateTaskReviewStatus = function(dispatch, taskId, newStatus, comment, ta
     api.task.updateReviewStatus,
     {schema: taskSchema(),
      variables: {id: taskId, status: newStatus},
-     params:{comment: comment, tags: tags}}
+     params:{comment: comment, tags: tags, newTaskStatus: newTaskStatus}}
   ).execute().catch(error => {
     if (isSecurityError(error)) {
       dispatch(ensureUserLoggedIn()).then(() =>
diff --git a/src/services/Task/TaskStatus/TaskStatus.js b/src/services/Task/TaskStatus/TaskStatus.js
index c99a49f1..26a8c1ab 100644
--- a/src/services/Task/TaskStatus/TaskStatus.js
+++ b/src/services/Task/TaskStatus/TaskStatus.js
@@ -52,8 +52,18 @@ export const TaskStatusColors = Object.freeze({
  *
  * @returns a Set of allowed status progressions
  */
-export const allowedStatusProgressions = function(status, includeSelf = false) {
+export const allowedStatusProgressions = function(status, includeSelf = false, forRevision = false) {
   let progressions = null
+
+  if (forRevision) {
+    progressions = new Set([TaskStatus.fixed, TaskStatus.falsePositive,
+                            TaskStatus.alreadyFixed, TaskStatus.tooHard])
+    if (!includeSelf) {
+      progressions.delete(status)
+    }
+    return progressions
+  }
+
   switch(status) {
     case TaskStatus.created:
       progressions = new Set([TaskStatus.fixed, TaskStatus.falsePositive,
diff --git a/src/services/User/User.js b/src/services/User/User.js
index 88ab53d0..6bf80c63 100644
--- a/src/services/User/User.js
+++ b/src/services/User/User.js
@@ -798,6 +798,11 @@ const reduceUsersFurther = function(mergedState, oldState, userEntities) {
       mergedState[entity.id].properties = entity.properties
     }
 
+    // Always completely replace customBasemaps
+    if (_isArray(_get(entity, 'settings.customBasemaps'))) {
+      mergedState[entity.id].settings.customBasemaps = entity.settings.customBasemaps
+    }
+
     // Normalize server's default `en` locale to `en-US`
     if (_get(entity, 'settings.locale') === 'en') {
       mergedState[entity.id].settings.locale = Locale.enUS
diff --git a/src/services/VirtualChallenge/VirtualChallenge.js b/src/services/VirtualChallenge/VirtualChallenge.js
index 41fe83dd..03d1849f 100644
--- a/src/services/VirtualChallenge/VirtualChallenge.js
+++ b/src/services/VirtualChallenge/VirtualChallenge.js
@@ -88,7 +88,7 @@ export const createVirtualChallenge = function(name, taskIds, expiration, cluste
       taskIdList: taskIds,
       expiry: expiration ? expiration :
               addHours(new Date(), DEFAULT_EXPIRATION_DURATION).getTime(),
-      searchParameters
+      searchParameters: searchParameters || {},
     }
 
     return saveVirtualChallenge(
diff --git a/src/services/VisibleLayer/LayerSources.js b/src/services/VisibleLayer/LayerSources.js
index f2b6be07..8712973f 100644
--- a/src/services/VisibleLayer/LayerSources.js
+++ b/src/services/VisibleLayer/LayerSources.js
@@ -1,10 +1,10 @@
 import PropTypes from 'prop-types'
 import QueryString from 'query-string'
-import _isEmpty from 'lodash/isEmpty'
 import _find from 'lodash/find'
 import _get from 'lodash/get'
 import _sortBy from 'lodash/sortBy'
 import _map from 'lodash/map'
+import _isFinite from 'lodash/isFinite'
 import { ChallengeBasemap, basemapLayerSources }
        from '../Challenge/ChallengeBasemap/ChallengeBasemap'
 import defaultLayers from '../../defaultLayers.json'
@@ -127,24 +127,55 @@ export const defaultLayerSource = function() {
   return configuredDefault ? configuredDefault : LayerSources[0]
 }
 
+/**
+ * Builds layers with the given function.
+ */
+export const buildLayerSources = function(visibleLayers, userCustomSources, buildLayer) {
+  return _map(visibleLayers, (layerId, index) => {
+    const layerSource = layerSourceWithId(layerId, userCustomSources)
+    if (layerSource) {
+      return buildLayer(layerId, index, layerSource)
+    }
+    else {
+      return null
+    }
+  })
+}
+
 /**
  * Retrieves the (static) layer source with a matching id. Dynamic layer
  * sources are not searched.
  */
-export const layerSourceWithId = function(id) {
-  return _find(LayerSources, {id})
+export const layerSourceWithId = function(id, userCustomSources) {
+  if (_find(LayerSources, {id})) {
+    return _find(LayerSources, {id})
+  }
+
+  const customSource = _find(userCustomSources, source => source.name === id)
+  if (customSource) {
+    return {
+      id: customSource.name,
+      name: customSource.name,
+      url: customSource.url,
+      overlay: customSource.overlay,
+      type: "tms"
+    }
+  }
+
+  return undefined
 }
 
 /**
  * Create and return a dynamic layer source with the given layerId
  * and url. Primarily intended for use with custom basemaps.
  */
-export const createDynamicLayerSource = function(layerId, url) {
+export const createDynamicLayerSource = function(layerId, layerName, url, overlay = false) {
   return {
     id: layerId,
-    name: 'Custom Imagery',
+    name: layerName,
     url,
     isDynamic: true,
+    overlay
   }
 }
 
@@ -153,19 +184,22 @@ export const createDynamicLayerSource = function(layerId, url) {
  * customBasemap settings, including generating a dynamic layer source with the
  * given customBasemap and customLayerId if appropriate.
  */
-export const basemapLayerSource = function(defaultBasemap, defaultBasemapId, customBasemap, customLayerId) {
+export const basemapLayerSource = function(defaultBasemap, defaultBasemapId) {
   if (defaultBasemap === ChallengeBasemap.none) {
     return null
   }
   else if (defaultBasemap === ChallengeBasemap.identified) {
     return layerSourceWithId(defaultBasemapId)
   }
-  else if (defaultBasemap !== ChallengeBasemap.custom) {
+  else if (_isFinite(defaultBasemap)) {
     return layerSourceWithId(basemapLayerSources()[defaultBasemap])
   }
-  else if (!_isEmpty(customBasemap)) {
-    return createDynamicLayerSource(customLayerId, customBasemap)
+  else if (defaultBasemap && defaultBasemap.url) {
+    return createDynamicLayerSource(
+      defaultBasemap.name, defaultBasemap.name, defaultBasemap.url,
+      defaultBasemap.overlay)
+  }
+  else {
+    return null
   }
-
-  return null
 }
diff --git a/src/styles/components/forms.css b/src/styles/components/forms.css
index 97e754fa..beaca7ec 100644
--- a/src/styles/components/forms.css
+++ b/src/styles/components/forms.css
@@ -45,6 +45,20 @@
       }
     }
   }
+
+  .custom-basemaps-item {
+    .fieldset {
+      display: block;
+
+      .field {
+        padding-bottom: 10px;
+      }
+
+      .checkbox {
+        padding-top: 10px;
+      }
+    }
+  }
 }
 
 .control-label, .form-group.field-boolean label span, .form-group.field-number label span {
diff --git a/src/styles/utilities/typography.css b/src/styles/utilities/typography.css
index 614193fb..d6a01529 100644
--- a/src/styles/utilities/typography.css
+++ b/src/styles/utilities/typography.css
@@ -231,7 +231,11 @@ h4,
 
   &--compact {
     p {
-      @apply mr-my-0;
+      @apply mr-my-0 mr-mt-4;
+    }
+
+    p:first-child {
+      @apply mr-mt-0;
     }
   }
 }
diff --git a/src/tailwind.config.js b/src/tailwind.config.js
index 128d22ae..4afd2f73 100644
--- a/src/tailwind.config.js
+++ b/src/tailwind.config.js
@@ -406,6 +406,7 @@ module.exports = {
       '8': '2rem',
       '10': '2.5rem',
       '12': '3rem',
+      '12.8': '3.2rem',
       '14': '3.5rem',
       '16': '4rem',
       '20': '5rem',
diff --git a/yarn.lock b/yarn.lock
index d0a9b791..01d487cb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2299,13 +2299,21 @@
     "@turf/helpers" "6.x"
     "@turf/meta" "6.x"
 
-"@turf/bearing@6.x":
+"@turf/bearing@6.x", "@turf/bearing@^6.0.1":
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/@turf/bearing/-/bearing-6.0.1.tgz#8da5d17092e571f170cde7bfb2e5b0d74923c92d"
   dependencies:
     "@turf/helpers" "6.x"
     "@turf/invariant" "6.x"
 
+"@turf/bearing@^5.1.5":
+  version "5.1.5"
+  resolved "https://registry.yarnpkg.com/@turf/bearing/-/bearing-5.1.5.tgz#7a0b790136c4ef4797f0246305d45cbe2d27b3f7"
+  integrity sha1-egt5ATbE70eX8CRjBdRcvi0ns/c=
+  dependencies:
+    "@turf/helpers" "^5.1.5"
+    "@turf/invariant" "^5.1.5"
+
 "@turf/boolean-disjoint@^6.0.2":
   version "6.0.2"
   resolved "https://registry.yarnpkg.com/@turf/boolean-disjoint/-/boolean-disjoint-6.0.2.tgz#5d6d0f4b4b8d5fe03004d74d978044c7529b294f"
@@ -2344,6 +2352,14 @@
     "@turf/helpers" "6.x"
     "@turf/invariant" "6.x"
 
+"@turf/destination@^5.1.5":
+  version "5.1.5"
+  resolved "https://registry.yarnpkg.com/@turf/destination/-/destination-5.1.5.tgz#ed35381bdce83bbddcbd07a2e2bce2bddffbcc26"
+  integrity sha1-7TU4G9zoO73cvQei4rzivd/7zCY=
+  dependencies:
+    "@turf/helpers" "^5.1.5"
+    "@turf/invariant" "^5.1.5"
+
 "@turf/distance@6.x", "@turf/distance@^6.0.1":
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/@turf/distance/-/distance-6.0.1.tgz#0761f28784286e7865a427c4e7e3593569c2dea8"
@@ -2351,16 +2367,36 @@
     "@turf/helpers" "6.x"
     "@turf/invariant" "6.x"
 
-"@turf/helpers@6.x":
+"@turf/distance@^5.1.5":
+  version "5.1.5"
+  resolved "https://registry.yarnpkg.com/@turf/distance/-/distance-5.1.5.tgz#39cf18204bbf87587d707e609a60118909156409"
+  integrity sha1-Oc8YIEu/h1h9cH5gmmARiQkVZAk=
+  dependencies:
+    "@turf/helpers" "^5.1.5"
+    "@turf/invariant" "^5.1.5"
+
+"@turf/helpers@6.x", "@turf/helpers@^6.1.4":
   version "6.1.4"
   resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-6.1.4.tgz#d6fd7ebe6782dd9c87dca5559bda5c48ae4c3836"
 
+"@turf/helpers@^5.1.5":
+  version "5.1.5"
+  resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-5.1.5.tgz#153405227ab933d004a5bb9641a9ed999fcbe0cf"
+  integrity sha1-FTQFInq5M9AEpbuWQantmZ/L4M8=
+
 "@turf/invariant@6.x", "@turf/invariant@^6.1.2":
   version "6.1.2"
   resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-6.1.2.tgz#6013ed6219f9ac2edada9b31e1dfa5918eb0a2f7"
   dependencies:
     "@turf/helpers" "6.x"
 
+"@turf/invariant@^5.1.5":
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-5.2.0.tgz#f0150ff7290b38577b73d088b7932c1ee0aa90a7"
+  integrity sha1-8BUP9ykLOFd7c9CIt5MsHuCqkKc=
+  dependencies:
+    "@turf/helpers" "^5.1.5"
+
 "@turf/line-intersect@6.x":
   version "6.0.2"
   resolved "https://registry.yarnpkg.com/@turf/line-intersect/-/line-intersect-6.0.2.tgz#b45b7a4e7e08c39a0e4e91099580ed377ef7335f"
@@ -2379,12 +2415,22 @@
     "@turf/invariant" "6.x"
     "@turf/meta" "6.x"
 
-"@turf/meta@6.x":
+"@turf/meta@6.x", "@turf/meta@^6.0.2":
   version "6.0.2"
   resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-6.0.2.tgz#eb92951126d24a613ac1b7b99d733fcc20fd30cf"
   dependencies:
     "@turf/helpers" "6.x"
 
+"@turf/midpoint@^5.1.5":
+  version "5.1.5"
+  resolved "https://registry.yarnpkg.com/@turf/midpoint/-/midpoint-5.1.5.tgz#e261f6b2b0ea8124cceff552a262dd465c9d05f0"
+  integrity sha1-4mH2srDqgSTM7/VSomLdRlydBfA=
+  dependencies:
+    "@turf/bearing" "^5.1.5"
+    "@turf/destination" "^5.1.5"
+    "@turf/distance" "^5.1.5"
+    "@turf/helpers" "^5.1.5"
+
 "@turf/nearest-point-on-line@^6.0.2":
   version "6.0.2"
   resolved "https://registry.yarnpkg.com/@turf/nearest-point-on-line/-/nearest-point-on-line-6.0.2.tgz#4b2c1177595d9a1743af76905a9013ced03bc0dc"
@@ -3442,8 +3488,8 @@ bluebird@^3.5.5:
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.5.tgz#a8d0afd73251effbbd5fe384a77d73003c17a71f"
 
 bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
-  version "4.11.9"
-  resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828"
+  version "4.11.8"
+  resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
 
 body-parser@1.19.0:
   version "1.19.0"
@@ -5141,8 +5187,8 @@ electron-to-chromium@^1.3.378, electron-to-chromium@^1.3.413:
   resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.473.tgz#d0cd5fe391046fb70674ec98149f0f97609d29b8"
 
 elliptic@^6.0.0:
-  version "6.5.3"
-  resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6"
+  version "6.4.1"
+  resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.1.tgz#c2d0b7776911b86722c632c3c06c60f2f819939a"
   dependencies:
     bn.js "^4.4.0"
     brorand "^1.0.1"
@@ -8109,9 +8155,9 @@ leaflet-lasso@^2.1.0:
   dependencies:
     "@terraformer/spatial" "^2.0.7"
 
-"leaflet-vectoricon@https://github.com/nrotstan/Leaflet.VectorIcon.git#with-viewbox":
+"leaflet-vectoricon@https://github.com/nrotstan/Leaflet.VectorIcon.git#with-transforms":
   version "0.2.0"
-  resolved "https://github.com/nrotstan/Leaflet.VectorIcon.git#d68290932dcb0fb05c04bce06d1cd3e856ce59b2"
+  resolved "https://github.com/nrotstan/Leaflet.VectorIcon.git#beaa664f96e05c115e8e50d7128be6bfaca9d055"
 
 leaflet.markercluster@^1.4.1:
   version "1.4.1"


Test patch:
diff --git a/src/services/VisibleLayer/LayerSources.test.js b/src/services/VisibleLayer/LayerSources.test.js
index 2d18e738..03f1ac76 100644
--- a/src/services/VisibleLayer/LayerSources.test.js
+++ b/src/services/VisibleLayer/LayerSources.test.js
@@ -11,10 +11,12 @@ import { BING,
 
 let layerId = null
 let layerUrl = null
+let layerName = null
 
 beforeEach(() => {
   layerId = "fooLayer"
   layerUrl = "http://www.example.com/foo"
+  layerName = "fooLayerName"
 })
 
 describe("layerSourceWithId", () => {
@@ -35,9 +37,10 @@ describe("layerSourceWithId", () => {
 
 describe("createDynamicLayerSource", () => {
   test("Generates layer source with the given id and url", () => {
-    const layer = createDynamicLayerSource(layerId, layerUrl)
+    const layer = createDynamicLayerSource(layerId, layerName, layerUrl)
 
     expect(layer.id).toEqual(layerId)
+    expect(layer.name).toEqual(layerName)
     expect(layer.url).toEqual(layerUrl)
     expect(layer.isDynamic).toBe(true)
   })
@@ -45,22 +48,22 @@ describe("createDynamicLayerSource", () => {
 
 describe("basemapLayerSource", () => {
   test("Returns a constant layer if defaultBasemap setting matches", () => {
-    const layer = basemapLayerSource(ChallengeBasemap.openStreetMap, null, null, layerId)
+    const layer = basemapLayerSource(ChallengeBasemap.openStreetMap, layerId)
     expect(layer.id).toEqual(OPEN_STREET_MAP)
   })
 
   test("Returns an identified layer if defaultBasemap setting matches", () => {
-    const layer = basemapLayerSource(ChallengeBasemap.identified, 'osm-mapnik-black_and_white', null, layerId)
+    const layer = basemapLayerSource(ChallengeBasemap.identified, 'osm-mapnik-black_and_white')
     expect(layer.id).toEqual('osm-mapnik-black_and_white')
   })
 
   test("Returns null if basemap set to none", () => {
-    const layer = basemapLayerSource(ChallengeBasemap.none, null, null, layerId)
+    const layer = basemapLayerSource(ChallengeBasemap.none, layerId)
     expect(layer).toBeNull()
   })
 
   test("Returns custom layer if set to custom and url provided", () => {
-    const layer = basemapLayerSource(ChallengeBasemap.custom, null, layerUrl, layerId)
+    const layer = basemapLayerSource({id: layerId, name: layerId, url: layerUrl}, layerId)
 
     expect(layer.id).toEqual(layerId)
     expect(layer.url).toEqual(layerUrl)
@@ -68,7 +71,7 @@ describe("basemapLayerSource", () => {
   })
 
   test("Returns null for custom layer if no url provided", () => {
-    const layer = basemapLayerSource(ChallengeBasemap.custom, null, null, layerId)
+    const layer = basemapLayerSource({id: layerId, name: layerId}, layerId)
 
     expect(layer).toBeNull()
   })