Travel app syncbase integration

 Syncbase for messages and trip plans
 SyncGroups for devices with a single user's blessing
 Adding es6-shim and updating makefile to use v23 standard node version

Change-Id: I0b1ad88ff74bf607826241521174defc6231ac14
diff --git a/.jshintrc b/.jshintrc
index 16f23d1..69274a9 100644
--- a/.jshintrc
+++ b/.jshintrc
@@ -24,6 +24,9 @@
   "node": true,
 
   "globals": {
-    "window": true
+    "window": true,
+    "Map": true,
+    "Promise": true,
+    "Set": true
   }
 }
diff --git a/Makefile b/Makefile
index f8cee03..7bcaa39 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
 PATH := node_modules/.bin:$(PATH)
-PATH := $(PATH):$(V23_ROOT)/third_party/cout/node/bin:$(V23_ROOT)/release/go/bin
+PATH := $(V23_ROOT)/third_party/cout/node/bin:$(V23_ROOT)/release/go/bin:$(PATH)
 
 .DEFAULT_GOAL := all
 
diff --git a/README.md b/README.md
index ab650b9..b0bad54 100644
--- a/README.md
+++ b/README.md
@@ -37,10 +37,27 @@
 
     make bootstrap
 
+or
+
+    make boostrap port=<syncbase port>
+
+Related targets:
+
+    make creds
+    make syncbase [port=<syncbase port>]
+
 To run a local dev server use:
 
     make start
 
-If you would like to change the host and or port that is used:
+If you would like to change the port that is used:
 
     make start port=<port>
+
+To connect to a syncbase instance other than the default, navigate to:
+
+    localhost:<server port>
+
+or
+
+    localhost:<server port>/?syncbase=<syncbase port>
diff --git a/mocks/google-maps.js b/mocks/google-maps.js
index 3ef6412..6318d52 100644
--- a/mocks/google-maps.js
+++ b/mocks/google-maps.js
@@ -149,6 +149,7 @@
   },
 
   places: {
+    PlacesService: function(){},
     SearchBox: SearchBox,
     mockPlaceResult: {
       geometry: {}
diff --git a/mocks/vanadium-wrapper.js b/mocks/vanadium-wrapper.js
index 446f03e..fa9eade 100644
--- a/mocks/vanadium-wrapper.js
+++ b/mocks/vanadium-wrapper.js
@@ -2,10 +2,10 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-var $ = require('../src/util/jquery');
+var Deferred = require('vanadium/src/lib/deferred');
 
 module.exports = {
   init: function(){
-    return $.Deferred().promise();
+    return new Deferred().promise;
   }
 };
\ No newline at end of file
diff --git a/package.json b/package.json
index 5045df4..65b6895 100644
--- a/package.json
+++ b/package.json
@@ -13,9 +13,13 @@
     "tape": "^4.0.0"
   },
   "dependencies": {
+    "date-format": "^0.0.2",
     "es6-promisify": "^2.0.0",
+    "es6-shim": "^0.33.0",
     "global": "^4.3.0",
     "jquery": "^2.1.4",
+    "lodash": "^3.10.1",
+    "query-string": "^2.4.0",
     "raf": "^3.1.0",
     "uuid": "^2.0.1"
   }
diff --git a/src/components/destination-marker.js b/src/components/destination-marker.js
index 9f4319f..a68d411 100644
--- a/src/components/destination-marker.js
+++ b/src/components/destination-marker.js
@@ -38,6 +38,7 @@
   publics: {
     clear: function() {
       this.marker.setMap(null);
+      this.onClear();
     },
 
     pushClient: function(client, color) {
@@ -76,6 +77,15 @@
       return this.topClient().client;
     },
 
+    hasClient: function(client) {
+      for (var i = 0; i < this.clients.length; i++) {
+        if (client === this.clients[0].client) {
+          return true;
+        }
+      }
+      return false;
+    },
+
     setColor: function(color) {
       this.topClient().color = color;
       this.updateIcon();
@@ -136,7 +146,7 @@
     }
   },
 
-  events: [ 'onClick' ],
+  events: [ 'onClick', 'onClear' ],
   constants: [ 'marker', 'place' ],
 
   /**
diff --git a/src/components/destination-search.js b/src/components/destination-search.js
index 7323794..dc3b989 100644
--- a/src/components/destination-search.js
+++ b/src/components/destination-search.js
@@ -73,6 +73,10 @@
 
     setPlaceholder: function(placeholder) {
       this.$searchBox.attr('placeholder', placeholder);
+    },
+
+    getValue: function() {
+      return this.$searchBox.prop('value');
     }
   },
 
@@ -115,6 +119,12 @@
           $(newBox).focus();
         }
       }
+    },
+
+    inputKey: function(e) {
+      if (e.which === 13) {
+        this.onSubmit(this.getValue());
+      }
     }
   },
 
@@ -135,7 +145,16 @@
      */
     'onSearch',
 
-    'onDeselect'
+    'onDeselect',
+
+    /**
+     * Event fired when the enter key is pressed. This is distinct from the
+     * onSearch event, which is fired when valid location properties are chosen,
+     * which can happen without onSubmit in the case of an autocomplete.
+     *
+     * @param value the current control text.
+     */
+    'onSubmit'
   ],
 
   constants: ['$'],
@@ -154,7 +173,7 @@
     $searchBox.focus(this.onFocus);
     $searchBox.on('input', function() {
       self.setPlace(null);
-    });
+    }).keypress(this.inputKey);
 
     this.$ = $('<div>')
       .addClass('destination autocomplete')
diff --git a/src/components/map.js b/src/components/map-widget.js
similarity index 78%
rename from src/components/map.js
rename to src/components/map-widget.js
index 24ab5db..0143586 100644
--- a/src/components/map.js
+++ b/src/components/map-widget.js
@@ -2,10 +2,11 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+require('es6-shim');
+
 var $ = require('../util/jquery');
 var defineClass = require('../util/define-class');
 
-var Destinations = require('../destinations');
 var Place = require('../place');
 var DestinationInfo = require('./destination-info');
 var DestinationMarker = require('./destination-marker');
@@ -15,7 +16,7 @@
 //named destination marker clients
 var SEARCH_CLIENT = 'search';
 
-var Map = defineClass({
+var MapWidget = defineClass({
   publics: {
     getBounds: function() {
       return this.map.getBounds();
@@ -28,31 +29,18 @@
       });
     },
 
-    addDestination: function(index) {
-      var self = this;
+    bindDestinations: function(destinations) {
+      if (this.destinations) {
+        this.destinations.onAdd.remove(this.handleDestinationAdd);
+        this.destinations.onRemove.remove(this.handleDestinationRemove);
+      }
 
-      var destination = this.destinations.add(index);
+      this.destinations = destinations;
 
-      destination.onPlaceChange.add(function(place) {
-        self.handleDestinationPlaceChange(destination, place);
-      });
-      destination.onDeselect.add(function() {
-        self.handleDestinationDeselect(destination);
-      });
-      destination.onSelect.add(function() {
-        self.handleDestinationSelect(destination);
-      });
-
-      return destination;
-    },
-
-    getDestination: function(index) {
-      return this.destinations.get(index);
-    },
-
-    removeDestination: function(index) {
-      //TODO(rosswang): clear any rendered legs
-      return this.destinations.remove(index);
+      if (destinations) {
+        destinations.onAdd.add(this.handleDestinationAdd);
+        destinations.onRemove.add(this.handleDestinationRemove);
+      }
     },
 
     getSelectedDestination: function() {
@@ -123,6 +111,10 @@
       this.locationSelectionEnabled = false;
     },
 
+    createPlacesService: function() {
+      return new this.maps.places.PlacesService(this.map);
+    },
+
     showSearchResults: function(results) {
       var self = this;
 
@@ -139,12 +131,11 @@
          * click and a normal search so that we don't overwrite the search box
          * text for the autocomplete click.*/
         dest.setPlace(new Place(results[0]));
-        self.createDestinationMarker(dest);
       } else if (results.length > 0) {
         $.each(results, function(i, result) {
           var place = new Place(result);
 
-          var marker = self.createMarker(place, SEARCH_CLIENT,
+          var marker = self.getOrCreateMarker(place, SEARCH_CLIENT,
             DestinationMarker.color.RED);
           self.searchMarkers.push(marker);
 
@@ -152,7 +143,6 @@
             var dest = self.selectedDestination;
             if (dest) {
               dest.setPlace(place);
-              self.associateDestinationMarker(dest, marker);
             }
           }));
         });
@@ -161,42 +151,95 @@
   },
 
   privates: {
-    createMarker: function(place, client, color) {
+    handleDestinationAdd: function(destination) {
       var self = this;
 
-      var marker = new DestinationMarker(this.maps, this.map, place,
-        client, color);
+      this.destMeta.set(destination, {});
 
-      if (place.hasDetails()) {
-        marker.onClick.add(function() {
-          self.showDestinationInfo(marker);
-        }, true);
+      destination.onPlaceChange.add(function(place) {
+        self.handleDestinationPlaceChange(destination, place);
+      });
+      destination.onDeselect.add(function() {
+        self.handleDestinationDeselect(destination);
+      });
+      destination.onSelect.add(function() {
+        self.handleDestinationSelect(destination);
+      });
+
+      if (destination.hasNext()) {
+        this.updateLeg(destination.getNext());
+      }
+
+      return destination;
+    },
+
+    handleDestinationRemove: function(destination) {
+      var meta = this.destMeta.get(destination);
+
+      if (meta.unbindMarker) {
+        meta.unbindMarker();
+      }
+
+      if (meta.leg) {
+        meta.leg.clear();
+      }
+
+      var next = this.destinations.get(destination.getIndex());
+      if (next) {
+        this.updateLeg(next);
+      }
+
+      destination.deselect();
+
+      this.destMeta.delete(destination);
+    },
+
+    getOrCreateMarker: function(place, client, color, mergePredicate) {
+      var self = this;
+
+      var key = place.toKey();
+
+      var marker = this.markers[key];
+      if (marker) {
+        if (!mergePredicate || mergePredicate(marker)) {
+          marker.pushClient(client, color);
+        } else {
+          marker = null;
+        }
+      } else {
+        marker = new DestinationMarker(this.maps, this.map, place,
+          client, color);
+
+        if (place.hasDetails()) {
+          marker.onClick.add(function() {
+            self.showDestinationInfo(marker);
+          }, true);
+        }
+
+        this.markers[key] = marker;
+        marker.onClear.add(function() {
+          delete self.markers[key];
+        });
       }
 
       return marker;
     },
 
-    createDestinationMarker: function(destination) {
-      var marker = this.createMarker(destination.getPlace(), destination,
-        this.getAppropriateDestinationMarkerColor(destination));
-
-      this.bindDestinationMarker(destination, marker);
-
-      return marker;
-    },
-
-    associateDestinationMarker: function(destination, marker) {
-      if (!marker.onClick.has(destination.select)) {
-        marker.pushClient(destination,
-          this.getAppropriateDestinationMarkerColor(destination));
-
-        this.bindDestinationMarker(destination, marker);
-      }
-    },
-
-    bindDestinationMarker: function(destination, marker) {
+    bindDestinationMarker: function(destination) {
       var self = this;
 
+      var place = destination.getPlace();
+
+      var marker = this.getOrCreateMarker(place, destination,
+        this.getAppropriateDestinationMarkerColor(destination),
+        function(marker) {
+          return !marker.hasClient(destination);
+        });
+
+      if (!marker) {
+        return;
+      }
+
       marker.onClick.add(destination.select);
       function handleSelection() {
         marker.setColor(self.getAppropriateDestinationMarkerColor(destination));
@@ -211,13 +254,26 @@
       destination.onOrdinalChange.add(handleOrdinalChange);
       handleOrdinalChange();
 
-      function handlePlaceChange() {
+      var meta = this.destMeta.get(destination);
+
+      function unbind() {
         marker.removeClient(destination);
         marker.onClick.remove(destination.select);
         destination.onSelect.remove(handleSelection);
         destination.onDeselect.remove(handleSelection);
         destination.onOrdinalChange.remove(handleOrdinalChange);
         destination.onPlaceChange.remove(handlePlaceChange);
+        if (meta.unbindMarker === unbind) {
+          delete meta.unbindMarker;
+        }
+      }
+
+      meta.unbindMarker = unbind;
+
+      function handlePlaceChange(newPlace) {
+        if ((place && place.toKey()) !== (newPlace && newPlace.toKey())) {
+          unbind();
+        }
       }
 
       destination.onPlaceChange.add(handlePlaceChange);
@@ -240,6 +296,10 @@
     },
 
     handleDestinationPlaceChange: function(destination, place) {
+      if (place) {
+        this.bindDestinationMarker(destination);
+      }
+
       if (destination.getPrevious()) {
         this.updateLeg(destination);
       }
@@ -280,19 +340,26 @@
       var a = destination.getPrevious().getPlace();
       var b = destination.getPlace();
 
-      var leg = destination.leg;
+      var meta = this.destMeta.get(destination);
+
+      var leg = meta.leg;
       if (leg) {
         if (leg.async) {
           leg.async.reject();
         }
-        // setMap(null) seems to be the best way to clear the nav route
-        leg.renderer.setMap(null);
+        leg.clear();
       } else {
         var renderer = new maps.DirectionsRenderer({
           preserveViewport: true,
           suppressMarkers: true
         });
-        destination.leg = leg = { renderer: renderer };
+        meta.leg = leg = {
+          renderer: renderer,
+          clear: function() {
+            // setMap(null) seems to be the best way to clear the nav route
+            renderer.setMap(null);
+          }
+        };
       }
 
       if (a && b) {
@@ -317,11 +384,6 @@
         leg.async.done(function(result) {
           leg.renderer.setDirections(result);
           leg.renderer.setMap(map);
-
-          self.ensureGeomsVisible(result.routes[0]['overview_path'].map(
-            function(point) {
-              return { location: point };
-            }));
         });
       }
     },
@@ -344,7 +406,6 @@
               if (status === maps.GeocoderStatus.OK &&
                   origin && !origin.hasPlace()) {
                 origin.setPlace(new Place(results[0]));
-                self.createDestinationMarker(origin);
               }
             });
           });
@@ -399,7 +460,6 @@
             function(results, status) {
               if (status === maps.GeocoderStatus.OK) {
                 dest.setPlace(new Place(results[0]));
-                self.createDestinationMarker(dest);
 
                 /* If we've just picked a location like this, we probably don't
                  * care about search results anymore. */
@@ -438,11 +498,12 @@
     this.navigator = opts.navigator || global.navigator;
     this.geocoder = new maps.Geocoder();
     this.directionsService = new maps.DirectionsService();
-    this.destinations = new Destinations();
 
     this.$ = $('<div>').addClass('map-canvas');
 
     this.searchMarkers = [];
+    this.markers = {};
+    this.destMeta = new Map();
 
     this.initialConfig = {
       center: new maps.LatLng(37.4184, -122.0880), //Googleplex
@@ -463,4 +524,4 @@
   }
 });
 
-module.exports = Map;
+module.exports = MapWidget;
diff --git a/src/components/message.js b/src/components/message.js
index a2e5062..e68ce89 100644
--- a/src/components/message.js
+++ b/src/components/message.js
@@ -2,6 +2,8 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+var format = require('date-format');
+
 var $ = require('../util/jquery');
 var defineClass = require('../util/define-class');
 
@@ -48,7 +50,31 @@
     },
 
     setText: function(text) {
-      this.$.text(text);
+      this.$text.text(text);
+    },
+
+    setTimestamp: function(timestamp) {
+      var fmt;
+      if (timestamp === null || timestamp === undefined) {
+        fmt = '';
+      } else {
+        fmt = format('yyyy.MM.dd.hh.mm.ss', new Date(timestamp));
+      }
+      this.$timestamp.text(fmt);
+      if (fmt) {
+        this.$label.removeClass('no-timestamp');
+      } else {
+        this.$label.addClass('no-timestamp');
+      }
+    },
+
+    setSender: function(sender) {
+      this.$sender.text(sender);
+      if (sender) {
+        this.$label.removeClass('no-sender');
+      } else {
+        this.$label.addClass('no-sender');
+      }
     },
 
     set: function(message) {
@@ -64,6 +90,8 @@
       var self = this;
 
       this.setType(message.type);
+      this.setSender(message.sender);
+      this.setTimestamp(message.timestamp);
       this.setText(message.text);
 
       if (message.promise) {
@@ -87,7 +115,12 @@
   },
 
   init: function(initial) {
-    this.$ = $('<li>');
+    this.$ = $('<li>')
+      .append(
+        this.$label = $('<span>').addClass('label').append(
+          this.$sender = $('<span>').addClass('username'),
+          this.$timestamp = $('<span>').addClass('timestamp')),
+        this.$text = $('<span>').addClass('text'));
     if (initial) {
       this.set(initial);
     }
diff --git a/src/components/messages.js b/src/components/messages.js
index 27d49a5..12b1087 100644
--- a/src/components/messages.js
+++ b/src/components/messages.js
@@ -13,7 +13,9 @@
     TTL: 9000,
     FADE: 1000,
     SLIDE_UP: 300,
-    OPEN_CLOSE: 400
+    OPEN_CLOSE: 400,
+
+    OLD: 30000
   },
 
   publics: {
@@ -81,17 +83,58 @@
               }
             });
         }
+
+        this.$content.focus();
       }
     },
 
     push: function(messageData) {
       var self = this;
+      $.each(arguments, function() {
+        self.pushOne(this);
+      });
+    },
+
+    setUsername: function(username) {
+      this.username = username;
+      this.$username.text(username);
+    },
+
+    toggle: function() {
+      /* If this were pure CSS, we could just toggle, but we need to do some
+       * JS housekeeping. */
+      if (this.isOpen()) {
+        this.close();
+      } else if (this.isClosed()) {
+        this.open();
+      }
+    }
+  },
+
+  privates: {
+    inputKey: function(e) {
+      if (e.which === 13) {
+        var message = Message.info(this.$content.prop('value'));
+        message.sender = this.username;
+        this.$content.prop('value', '');
+        this.onMessage(message);
+      }
+    },
+
+    pushOne: function(messageData) {
+      var self = this;
 
       var messageObject = new Message(messageData);
       this.$messages.append(messageObject.$);
 
+      var isOld = messageData.timestamp !== undefined &&
+        messageData.timestamp !== null &&
+        Date.now() - messageData.timestamp >= Messages.OLD;
+
       if (this.isOpen()) {
         this.$messages.scrollTop(this.$messages.prop('scrollHeight'));
+      } else if (isOld) {
+        messageObject.$.addClass('history');
       } else {
         /*
          * Implementation notes: slideDown won't work properly (won't be able to
@@ -115,47 +158,49 @@
           .slideDown(this.SLIDE_DOWN);
       }
 
-      messageObject.onLowerPriority.add(function() {
-        messageObject.$.addClass('history');
+      if (!isOld) {
+        messageObject.onLowerPriority.add(function() {
+          messageObject.$.addClass('history');
 
-        if (self.isClosed()) {
-          messageObject.$
-            .addClass('animating')
-            .show()
-            .delay(Messages.TTL)
-            .animate({ opacity: 0 }, Messages.FADE)
-            .slideUp(Messages.SLIDE_UP, function() {
-              messageObject.$
-                .removeClass('animating')
-                .attr('style', null);
-            });
-        }
-      });
-    },
-
-    toggle: function() {
-      /* If this were pure CSS, we could just toggle, but we need to do some
-       * JS housekeeping. */
-      if (this.isOpen()) {
-        this.close();
-      } else if (this.isClosed()) {
-        this.open();
+          if (self.isClosed()) {
+            messageObject.$
+              .addClass('animating')
+              .show()
+              .delay(Messages.TTL)
+              .animate({ opacity: 0 }, Messages.FADE)
+              .slideUp(Messages.SLIDE_UP, function() {
+                messageObject.$
+                  .removeClass('animating')
+                  .attr('style', null);
+              });
+          }
+        });
       }
     }
   },
 
   constants: ['$'],
+  events: [ 'onMessage' ],
 
   init: function() {
-    this.$handle = $('<div>')
+    var $handle = $('<div>')
       .addClass('handle no-select')
       .click(this.toggle);
 
     this.$messages = $('<ul>');
 
+    var $send = $('<div>')
+      .addClass('send')
+      .append(this.$username = $('<div>')
+                .addClass('username label'),
+              $('<div>').append(
+                this.$content = $('<input>')
+                  .attr('type', 'text')
+                  .keypress(this.inputKey)));
+
     this.$ = $('<div>')
       .addClass('messages headlines')
-      .append(this.$handle, this.$messages);
+      .append($handle, this.$messages, $send);
   }
 });
 
diff --git a/src/components/timeline.js b/src/components/timeline.js
index 24df0ab..10da699 100644
--- a/src/components/timeline.js
+++ b/src/components/timeline.js
@@ -18,12 +18,17 @@
       this.addButton.enable();
     },
 
-    append: function() {
+    add: function(i) {
       var controls = this.controls;
 
       var destinationSearch = new DestinationSearch(this.maps);
-      this.$destContainer.append(destinationSearch.$);
-      controls.push(destinationSearch);
+      if (i === undefined || i === controls.length) {
+        this.$destContainer.append(destinationSearch.$);
+        controls.push(destinationSearch);
+      } else {
+        destinationSearch.$.insertBefore(this.$destContainer.children()[i]);
+        controls.splice(i, 0, destinationSearch);
+      }
 
       return destinationSearch;
     },
@@ -39,11 +44,17 @@
     },
 
     remove: function(i) {
+      var removed;
       if (i >= 0) {
-        this.controls.splice(i, 1)[0].$.remove();
+        removed = this.controls.splice(i, 1)[0];
       } else if (i < 0) {
-        this.controls.splice(this.controls.length + i, 1)[0].$.remove();
+        removed = this.controls.splice(this.controls.length + i, 1)[0];
       }
+
+      if (removed) {
+        removed.$.remove();
+      }
+      return removed;
     }
   },
 
diff --git a/src/debug.js b/src/debug.js
index 12da771..8e47959 100644
--- a/src/debug.js
+++ b/src/debug.js
@@ -7,7 +7,15 @@
 /**
  * Global variable exports for console debug.
  */
-module.exports = function(app) {
+function debug(app) {
   global.travel = app;
   global.$ = $;
-};
\ No newline at end of file
+}
+
+debug.log = function(message) {
+  if (console.debug) {
+    console.debug(message);
+  }
+};
+
+module.exports = debug;
diff --git a/src/destination.js b/src/destination.js
index 25c609c..624149f 100644
--- a/src/destination.js
+++ b/src/destination.js
@@ -58,6 +58,10 @@
 
     getPrevious: function() {
       return this.hasPrevious()? this.list.get(this.index - 1) : null;
+    },
+
+    remove: function() {
+      this.list.remove(this.index);
     }
   },
 
@@ -88,6 +92,15 @@
     'onDeselect'
   ],
 
+  /**
+   * @param list the containing `Destinations` instance.
+   * @param index the index within the parent list
+   * @param callbacks an object that will be assigned members for utility
+   *  callbacks that the caller can use:
+   *    <ul>
+   *      <li>ordinalChange - fires this destination's `onOrdinalChange` event.
+   *    </ul>
+   */
   init: function(list, index, callbacks) {
     this.list = list;
     this.selected = false;
diff --git a/src/destinations.js b/src/destinations.js
index 2679a4e..4ae4415 100644
--- a/src/destinations.js
+++ b/src/destinations.js
@@ -9,7 +9,7 @@
 var Destinations = defineClass({
   publics: {
     add: function(index) {
-      index = index || this.destinations.length;
+      index = index !== undefined? index : this.destinations.length;
 
       var isLast = index === this.destinations.length;
 
@@ -21,8 +21,6 @@
         destination: destination
       });
 
-      this.onAdd(destination);
-
       if (isLast && index > 0) {
         //old last is no longer last
         this.destinations[index - 1].callbacks.ordinalChange(index - 1);
@@ -31,6 +29,8 @@
         this.destinations[i].callbacks.ordinalChange(i);
       }
 
+      this.onAdd(destination);
+
       return destination;
     },
 
@@ -66,8 +66,6 @@
 
       var removed = this.destinations.splice(i, 1)[0];
       if (removed) {
-        this.onRemove(removed);
-
         if (i === this.destinations.length && i > 0) {
           //new last
           this.destinations[i - 1].callbacks.ordinalChange(i - 1);
@@ -76,6 +74,8 @@
           this.destinations[j].callbacks.ordinalChange(j);
         }
 
+        this.onRemove(removed.destination);
+
         return removed.destination;
       }
     },
diff --git a/src/place.js b/src/place.js
index d961d08..5096a9d 100644
--- a/src/place.js
+++ b/src/place.js
@@ -2,9 +2,31 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+var Deferred = require('vanadium/src/lib/deferred');
+
 var defineClass = require('./util/define-class');
 
 var Place = defineClass({
+  statics: {
+    fromObject: function(dependencies, obj) {
+      var async = new Deferred();
+
+      if (obj.placeId) {
+        dependencies.placesService.getDetails(obj, function(place, status) {
+          if (status === dependencies.maps.places.PlacesServiceStatus.OK) {
+            async.resolve(new Place(place));
+          } else {
+            async.reject(status);
+          }
+        });
+      } else {
+        async.reject('Deserialization not supported.'); //TODO(rosswang)
+      }
+
+      return async.promise;
+    }
+  },
+
   publics: {
     getDetails: function() {
       return this.details;
@@ -124,6 +146,31 @@
       })();
 
       return lines[0] === name? lines.slice(1) : lines;
+    },
+
+    /**
+     * Returns a plain object that can be used to reconstruct the place. This
+     * object really shouldn't be mutated.
+     */
+    toObject: function() {
+      if (this.placeObj.placeId) {
+        return {
+          placeId: this.placeObj.placeId
+        };
+      } else {
+        return {
+          location: {
+            lat: this.placeObj.location.lat(),
+            lng: this.placeObj.location.lng()
+          },
+          query: this.placeObj.query
+        };
+      }
+    },
+
+    toKey: function() {
+      return this.placeObj.placeId ||
+        (this.placeObj.query || '') + this.placeObj.location.toString();
     }
   },
 
diff --git a/src/static/index.css b/src/static/index.css
index 5b69c32..db4d8ef 100644
--- a/src/static/index.css
+++ b/src/static/index.css
@@ -169,6 +169,67 @@
   color: red;
 }
 
+.label {
+  color: blue;
+  margin-right: .4em;
+}
+
+.messages.headlines .label {
+  background-color: rgba(255, 255, 255, .8);
+  border-radius: 3px;
+  padding: .1em .3em .1em .4em;
+}
+
+.label:after {
+  content: ':'
+}
+
+.label.no-timestamp.no-sender {
+  display: none;
+}
+
+.username {
+  font-weight: bold;
+}
+
+.timestamp:before {
+  content: ' (';
+}
+
+.timestamp:after {
+  content: ')';
+}
+
+.no-sender .timestamp:before {
+  content: initial;
+}
+
+.no-sender .timestamp:after {
+  content: initial;
+}
+
+.send {
+  background-color: silver;
+}
+
+.send div {
+  overflow: hidden;
+}
+
+.send input {
+  width: 100%;
+}
+
+.messages.headlines .send {
+  display: none;
+}
+
+.send .label {
+  background-color: initial;
+  float: left;
+  margin: .4em .5em .1em .5em;
+}
+
 .mini-search {
   overflow: hidden;
   vertical-align: middle;
diff --git a/src/strings.js b/src/strings.js
index bdd3a06..1a94323 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -5,6 +5,9 @@
 function getStrings(locale) {
   return {
     'Add destination': 'Add destination',
+    add: function(object) {
+      return 'Add ' + object.toLowerCase();
+    },
     change: function(object) {
       return 'Change ' + object.toLowerCase();
     },
diff --git a/src/travel.js b/src/travel.js
index 953f7f3..5b1c30c 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -2,17 +2,21 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-var $ = require('./util/jquery');
 var raf = require('raf');
+var vanadium = require('vanadium');
+
+var $ = require('./util/jquery');
 var defineClass = require('./util/define-class');
 
 var AddButton = require('./components/add-button');
 var DestinationSearch = require('./components/destination-search');
-var Identity = require('./identity');
-var Map = require('./components/map');
+var MapWidget = require('./components/map-widget');
 var Messages = require('./components/messages');
 var Message = require('./components/message');
 var Timeline = require('./components/timeline');
+
+var Destinations = require('./destinations');
+var Identity = require('./identity');
 var TravelSync = require('./travelsync');
 
 var vanadiumWrapperDefault = require('./vanadium-wrapper');
@@ -67,14 +71,37 @@
   control.setPlaceholder(describeDestination.descriptionOpenEnded(destination));
 }
 
+function makeMountNames(id) {
+  // TODO: first-class app-wide rather than siloed by account
+  var parts = ['/ns.dev.v.io:8101', 'users', id.username, 'travel'];
+  var names = {
+    user: vanadium.naming.join(parts)
+  };
+
+  parts.push(id.deviceName);
+  names.device = vanadium.naming.join(parts);
+
+  return names;
+}
+
 var Travel = defineClass({
   publics: {
-    addDestination: function() {
+    error: function (err) {
+      this.messages.push(Message.error(err));
+    },
+
+    info: function (info, promise) {
+      var messageData = Message.info(info);
+      messageData.promise = promise;
+      this.messages.push(messageData);
+    }
+  },
+
+  privates: {
+    handleDestinationAdd: function(destination) {
       var map = this.map;
 
-      var destination = map.addDestination();
-      var control = this.timeline.append();
-
+      var control = this.timeline.add(destination.getIndex());
       bindControlToDestination(control, destination);
 
       control.setSearchBounds(map.getBounds());
@@ -97,12 +124,14 @@
         map.showSearchResults(results);
       });
 
-      this.timeline.disableAdd();
-      var oldLast = this.timeline.get(-2);
-      if (oldLast) {
-        this.unbindLastDestinationSearchEvents(oldLast);
+      if (!destination.hasNext()) {
+        this.timeline.disableAdd();
+        var oldLast = this.timeline.get(-2);
+        if (oldLast) {
+          this.unbindLastDestinationSearchEvents(oldLast);
+        }
+        this.bindLastDestinationSearchEvents(control);
       }
-      this.bindLastDestinationSearchEvents(control);
 
       this.bindMiniFeedback(destination);
 
@@ -112,29 +141,47 @@
       };
     },
 
-    error: function (err) {
-      this.messages.push(Message.error(err));
+    handleDestinationRemove: function(destination) {
+      var index = destination.getIndex();
+      this.unbindLastDestinationSearchEvents(this.timeline.remove(index));
+
+      if (index >= this.destinations.count()) {
+        var lastControl = this.timeline.get(-1);
+        if (lastControl) {
+          this.bindLastDestinationSearchEvents(lastControl);
+          this.handleLastPlaceChange(lastControl.getPlace());
+        }
+      }
+      //TODO(rosswang): reselect?
     },
 
-    info: function (info, promise) {
-      var messageData = Message.info(info);
-      messageData.promise = promise;
-      this.messages.push(messageData);
-    }
-  },
+    handleTimelineDestinationAdd: function() {
+      this.destinations.add();
+      this.timeline.get(-1).focus();
+    },
 
-  privates: {
-    /**
-     * Handles destination addition via the mini-UI.
-     */
-    addDestinationMini: function() {
+    handleMiniDestinationAdd: function() {
       this.miniDestinationSearch.clear();
       this.map.closeActiveInfoWindow();
 
-      var destination = this.addDestination().destination;
+      var selectedDest = this.map.getSelectedDestination();
+      var index = selectedDest?
+        selectedDest.getIndex() + 1 : this.destinations.count();
+
+      var destination = this.destinations.get(index);
+      if (!destination || destination.hasPlace()) {
+        destination = this.destinations.add(index);
+      }
+
       destination.select();
       this.miniDestinationSearch.focus();
-      this.miniDestinationSearch.setPlaceholder(strings['Add destination']);
+      this.miniDestinationSearch.setPlaceholder(
+        destination.hasNext()?
+          /* Actually, the terminal case where descriptionOpenEnded would differ
+           * from description is always handled by the latter branch, but
+           * semantically we would want the open-ended description here. */
+          strings.add(describeDestination.descriptionOpenEnded(destination)) :
+          strings['Add destination']);
     },
 
     bindMiniFeedback: function(destination) {
@@ -147,6 +194,8 @@
     initMiniFeedback: function() {
       var self = this;
 
+      var selectedDestination;
+
       //context: destination
       function handlePlaceChange(place) {
         self.miniDestinationSearch.setPlace(place);
@@ -154,18 +203,23 @@
           strings.change(describeDestination.description(this)));
       }
 
-      //context: destination.
+      //context: destination
       function handleSelect() {
+        selectedDestination = this;
         handlePlaceChange.call(this, this.getPlace());
         this.onPlaceChange.add(handlePlaceChange);
       }
 
+      //context: destination
       function handleDeselect() {
         this.onPlaceChange.remove(handlePlaceChange);
-        if (self.miniDestinationSearch.getPlace()) {
-          self.miniDestinationSearch.clear();
+        if (selectedDestination === this) {
+          selectedDestination = null;
+          if (self.miniDestinationSearch.getPlace()) {
+            self.miniDestinationSearch.clear();
+          }
+          self.miniDestinationSearch.setPlaceholder(strings['Search']);
         }
-        self.miniDestinationSearch.setPlaceholder(strings['Search']);
       }
 
       this.miniFeedback = {
@@ -218,25 +272,18 @@
     },
 
     handleLastPlaceDeselected: function() {
-      var self = this;
       /* Wait until next frame to allow selection/focus to update; we don't want
        * to remove a box that has just received focus. */
-      raf(function() {
-        var lastControl = self.timeline.get(-1);
-        var oldLast = lastControl;
+      raf(this.trimUnusedDestinations);
+    },
 
-        while (!lastControl.getPlace() && !lastControl.isSelected() &&
-            self.timeline.get().length > 1) {
-          self.timeline.remove(-1);
-          self.map.removeDestination(-1);
-          lastControl = self.timeline.get(-1);
-        }
-
-        if (oldLast !== lastControl) {
-          self.bindLastDestinationSearchEvents(lastControl);
-          self.handleLastPlaceChange(lastControl.getPlace());
-        }
-      });
+    trimUnusedDestinations: function() {
+      for (var lastControl = this.timeline.get(-1);
+          !lastControl.getPlace() && !lastControl.isSelected() &&
+            this.destinations.count() > 1;
+          lastControl = this.timeline.get(-1)) {
+        this.destinations.remove(-1);
+      }
     },
 
     /**
@@ -267,24 +314,45 @@
     opts = opts || {};
     var vanadiumWrapper = opts.vanadiumWrapper || vanadiumWrapperDefault;
 
-    var map = this.map = new Map(opts);
+    var destinations = this.destinations = new Destinations();
+    destinations.onAdd.add(this.handleDestinationAdd);
+    destinations.onRemove.add(this.handleDestinationRemove);
+
+    var map = this.map = new MapWidget(opts);
     var maps = map.maps;
+    map.bindDestinations(destinations);
 
     var messages = this.messages = new Messages();
     var timeline = this.timeline = new Timeline(maps);
 
-    var sync = this.sync = new TravelSync();
-
     var error = this.error;
-
-    this.info(strings['Connecting...'], vanadiumWrapper.init(opts.vanadium)
+    var vanadiumStartup = vanadiumWrapper.init(opts.vanadium)
       .then(function(wrapper) {
+        wrapper.onError.add(error);
         wrapper.onCrash.add(error);
 
         var identity = new Identity(wrapper.getAccountName());
-        identity.mountName = makeMountName(identity);
-        return sync.start(identity.mountName, wrapper);
-      }).then(function() {
+        identity.mountNames = makeMountNames(identity);
+        messages.setUsername(identity.username);
+
+        return {
+          identity: identity,
+          vanadiumWrapper: wrapper
+        };
+      });
+
+    var sync = this.sync = new TravelSync(vanadiumStartup, {
+      maps: maps,
+      placesService: map.createPlacesService()
+    });
+    sync.bindDestinations(destinations);
+
+    this.info(strings['Connecting...'], sync.startup
+      .then(function() {
+        /* Fit whatever's in the map via timeout to simplify the coding a
+         * little. Otherwise we'd need to hook into the asynchronous place
+         * vivification and routing. */
+        setTimeout(map.fitAll, 2250);
         return strings['Connected to all services.'];
       }));
 
@@ -298,15 +366,22 @@
       error(message);
     });
 
-    timeline.onAddClick.add(function() {
-      self.addDestination().control.focus();
+    sync.onError.add(error);
+    sync.onMessages.add(function(messages) {
+      self.messages.push.apply(self.messages, messages);
     });
 
+    messages.onMessage.add(function(message) {
+      sync.message(message);
+    });
+
+    timeline.onAddClick.add(this.handleTimelineDestinationAdd);
+
     var miniAddButton = this.miniAddButton = new AddButton();
     var miniDestinationSearch = this.miniDestinationSearch =
       new DestinationSearch(maps);
 
-    miniAddButton.onClick.add(this.addDestinationMini);
+    miniAddButton.onClick.add(this.handleMiniDestinationAdd);
 
     miniDestinationSearch.setPlaceholder(strings['Search']);
     miniDestinationSearch.setSearchBounds(map.getBounds());
@@ -330,6 +405,17 @@
       }
     });
 
+    miniDestinationSearch.onSubmit.add(function(value) {
+      if (!value) {
+        var selected = self.map.getSelectedDestination();
+        if (selected) {
+          selected.remove();
+        }
+
+        self.map.clearSearchMarkers();
+      }
+    });
+
     var $miniPanel = this.$minPanel = $('<div>')
       .addClass('mini-search')
       .append(miniAddButton.$,
@@ -359,14 +445,9 @@
 
     this.initMiniFeedback();
 
-    this.addDestination();
+    destinations.add();
     miniDestinationSearch.focus();
   }
 });
 
-function makeMountName(id) {
-  // TODO: first-class app-wide rather than siloed by account
-  return 'users/' + id.username + '/travel/' + id.deviceName;
-}
-
 module.exports = Travel;
diff --git a/src/travelsync.js b/src/travelsync.js
index 3f458b3..baf55e7 100644
--- a/src/travelsync.js
+++ b/src/travelsync.js
@@ -2,33 +2,53 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-//TODO(rosswang): either expect ES6, use our own polyfill, or export this in V23
-var Promise = require('vanadium/src/lib/promise');
+require('es6-shim');
+
+var _ = require('lodash');
+var queryString = require('query-string');
+var uuid = require('uuid');
+var vanadium = require('vanadium');
 
 var $ = require('./util/jquery');
 var defineClass = require('./util/define-class');
 
+var debug = require('./debug');
+var Place = require('./place');
+
 var vdlTravel = require('../ifc');
 
-var TravelSync = defineClass({
-  publics: {
-    start: function(mountName, v) {
-      var self = this;
-      var startSyncbase = v.syncbase('/localhost:4001/syncbase').then(
-        function(syncbase) {
-          self.syncbase = syncbase;
-          syncbase.onError.add(self.onError);
-          syncbase.onUpdate.add(self.processUpdates);
-        });
+var DESTINATION_SCHEMA = [ 'place' ];
 
-      return Promise.all([
-        v.server(mountName, this.server),
-        startSyncbase
-      ]);
+var TravelSync = defineClass({
+  /* Schema note: although we don't support merging destination list structure
+   * changes, we use indirection in the destination list so that we don't have
+   * to move multiple keys on random insertion or deletion and can still support
+   * parallel destination edits. */
+  publics: {
+    bindDestinations: function(destinations) {
+      if (this.destinations) {
+        this.destinations.onAdd.remove(this.handleDestinationAdd);
+        this.destinations.onRemove.remove(this.handleDestinationRemove);
+      }
+
+      this.destinations = destinations;
+
+      if (destinations) {
+        destinations.onAdd.add(this.handleDestinationAdd);
+        destinations.onRemove.add(this.handleDestinationRemove);
+      }
     },
 
     message: function(messageContent) {
+      var id = uuid.v4();
+      var payload = $.extend({
+        timestamp: Date.now()
+      }, messageContent);
+      var value = this.marshal(payload);
 
+      this.startup.then(function(services) {
+        return services.syncbase.put(['messages', id], value);
+      }).catch(this.onError);
     },
 
     pushTrip: function() {
@@ -39,6 +59,196 @@
   },
 
   privates: {
+    destinationRecord: defineClass.innerClass({
+      publics: {
+        isValid: function() {
+          return this.id !== undefined;
+        },
+
+        invalidate: function() {
+          delete this.id;
+        },
+
+        getId: function() {
+          return this.id;
+        },
+
+        setId: function(id) {
+          this.id = id;
+        },
+
+        /**
+         * @param placeData the plain object representation of a `Place`.
+         * @param changedCallback a function called if the place is actually
+         *  changed, with the params newPlace, oldPlace, as the new and old
+         *  plain object places, respectively.
+         */
+        setPlaceData: function(placeData, changedCallback) {
+          var old = this.data.place;
+          if (!_.isEqual(old, placeData) && (old || placeData)) {
+            this.data.place = placeData;
+
+            this.cancelPlaceAsync();
+
+            if (changedCallback) {
+              changedCallback.call(this.ifc, placeData, old);
+            }
+          }
+        },
+
+        put: function(dao) {
+          var outer = this.outer;
+          var self = this;
+
+          if (this.isValid()) {
+            var key = ['destinations', this.id];
+            var writes = [];
+
+            $.each(DESTINATION_SCHEMA, function() {
+              key[2] = this;
+              var value = self.data[this];
+              writes.push(value?
+                dao.put(key, outer.marshal(value)) : dao.delete(key));
+            });
+            return Promise.all(writes);
+          } else {
+            return Promise.resolve();
+          }
+        },
+
+        delete: function(dao) {
+          if (this.isValid()) {
+            return dao.delete(['destinations', this.id]);
+          } else {
+            return Promise.resolve();
+          }
+        },
+      },
+
+      events: {
+        /**
+         * Utility event to allow asynchronous update processes to cancel if
+         * they do not finish by the time the place has been updated again.
+         */
+        cancelPlaceAsync: 'once'
+      },
+
+      init: function(place, generateId) {
+        if (generateId) {
+          this.id = uuid.v4();
+        }
+
+        this.data = {
+          place: place && place.toObject()
+        };
+      }
+    }),
+
+    batch: function(fn) {
+      this.startup.then(function(services) {
+        return services.syncbase.batch(fn);
+      }).catch(this.onError);
+    },
+
+    nonBatched: function(fn) {
+      var self = this; //not really necessary but semantically correct
+      var fnArgs = Array.prototype.slice.call(arguments, 1);
+      this.startup.then(function(services) {
+        fnArgs.splice(0, 0, services.syncbase);
+        return fn.apply(self, fnArgs);
+      }).catch(this.onError);
+    },
+
+    handleDestinationAdd: function (destination) {
+      var self = this;
+
+      var index = destination.getIndex();
+      var record = this.destRecords[index];
+
+      if (!record || record.isValid()) {
+        var place = destination.getPlace();
+
+        record = this.destinationRecord(place, true);
+
+        debug.log('Adding destination ' + index + ':' + record.getId());
+
+        this.destRecords.splice(index, 0, record);
+
+        if (this.hasUpstream) {
+          this.batch(function(ops) {
+            return Promise.all([
+              self.putDestinationIds(ops),
+              record.put(ops)
+            ]);
+          });
+        }
+      }
+
+      destination.onPlaceChange.add(this.handleDestinationPlaceChange);
+    },
+
+    handleDestinationRemove: function(destination) {
+      var self = this;
+
+      var index = destination.getIndex();
+      var removed = this.destRecords.splice(index, 1)[0];
+      if (this.hasUpstream && removed.isValid()) {
+        debug.log('Removing destination ' + index + ':' + removed.getId());
+        this.batch(function(ops) {
+          return Promise.all([
+            self.putDestinationIds(ops),
+            removed.delete(ops)
+          ]);
+        });
+      }
+    },
+
+    updateDestinationPlace: function(destination) {
+      var self = this;
+
+      var index = destination.getIndex();
+      var record = this.destRecords[index];
+      var place = destination.getPlace();
+      var placeData = place && place.toObject();
+
+      if (record && record.isValid()) {
+        record.setPlaceData(placeData, function(placeData, oldPlace) {
+          if (self.hasUpstream) {
+            debug.log('Updating destination ' + index + ':' + this.getId() +
+              '.place = ' + JSON.stringify(oldPlace) + ' => ' +
+              JSON.stringify(placeData));
+
+            self.nonBatched(this.put);
+          }
+        });
+      }
+    },
+
+    pushDestinations: function() {
+      var self = this;
+
+      this.batch(function(ops) {
+        var asyncs = self.destRecords.map(function(record) {
+          return record.put(ops);
+        });
+        asyncs.push(self.putDestinationIds(ops));
+        return Promise.all(asyncs);
+      });
+    },
+
+    /* A note on these operations: SyncBase client operations occur
+     * asynchronously, in response to events that can rapidly change state. As
+     * such, each write operation must first check to ensure the record it's
+     * updating for is still valid (has a defined id).
+     */
+
+    putDestinationIds: function(dao) {
+      var ids = this.destRecords
+        .filter(function(r) { return r.isValid(); })
+        .map(function(r) { return r.getId(); });
+      return dao.put(['destinations'], this.marshal(ids));
+    },
+
     marshal: function(x) {
       return JSON.stringify(x);
     },
@@ -47,13 +257,26 @@
       return JSON.parse(x);
     },
 
-    processUpdates: function(data) {
+    truncateDestinations: function(targetLength) {
+      if (this.destinations.count() > targetLength) {
+        debug.log('Truncating destinations to ' + targetLength);
+      }
+
+      while (this.destinations.count() > targetLength) {
+        var last = this.destinations.count() - 1;
+        this.destRecords[last].invalidate();
+        this.destinations.remove(last);
+      }
+    },
+
+    processMessages: function(messageData) {
       var self = this;
-      if (data.messages) {
+
+      if (messageData) {
         /* Dispatch new messages in time order, though don't put them before
          * local messages. */
         var newMessages = [];
-        $.each(data.messages, function(id, serializedMessage) {
+        $.each(messageData, function(id, serializedMessage) {
           if (!self.messages[id]) {
             var message = self.unmarshal(serializedMessage);
             newMessages.push(message);
@@ -66,45 +289,197 @@
                                              0;
         });
 
-        self.onMessages(newMessages);
+        this.onMessages(newMessages);
       }
+    },
+
+    processDestinations: function(destinationsData) {
+      var self = this;
+
+      if (!destinationsData) {
+        if (this.hasUpstream) {
+          this.truncateDestinations(0);
+        } else {
+          //first push with no remote data; push local data as authority
+          this.pushDestinations();
+        }
+
+      } else {
+        var ids;
+        try {
+          ids = this.unmarshal(destinationsData._ || destinationsData);
+        } catch(e) {
+          this.onError(e);
+          //assume it's corrupt and overwrite
+          this.pushDestinations();
+          return;
+        }
+
+        $.each(ids, function(i, id) {
+          /* Don't bother reordering existing destinations by ID; instead, just
+           * overwrite everything. TODO(rosswang): optimize to reorder. */
+          var record = self.destRecords[i];
+          var destination = self.destinations.get(i);
+
+          if (!record) {
+            /* Add the record invalid so that the destination add handler leaves
+             * population to this handler. */
+            record = self.destRecords[i] = self.destinationRecord();
+            destination = self.destinations.add(i);
+          }
+
+          if (record.getId() !== id) {
+            record.setId(id);
+            debug.log('Pulling destination ' + i + ':' + id);
+          }
+
+          var destinationData = destinationsData[id];
+          var newPlace = destinationData &&
+            self.unmarshal(destinationData.place);
+
+          record.setPlaceData(newPlace, function(newPlace, oldPlace) {
+            debug.log('Pulled update for destination ' + i + ':' + id +
+              '.place = ' + JSON.stringify(oldPlace) + ' => ' +
+              JSON.stringify(newPlace));
+
+            if (newPlace) {
+              var cancelled = false;
+              record.cancelPlaceAsync.add(function() {
+                cancelled = true;
+              });
+
+              Place.fromObject(self.mapsDeps, newPlace)
+                .catch(function(err) {
+                  //assume it's corrupt and overwrite
+                  if (!cancelled) {
+                    self.updateDestinationPlace(destination);
+                    throw err;
+                  }
+                })
+                .then(function(place) {
+                  if (!cancelled) {
+                    destination.setPlace(place);
+                  }
+                }).catch(function(err) {
+                  self.onError(err);
+                });
+            } else {
+              destination.setPlace(null);
+            }
+          });
+        });
+
+        if (this.destRecords.length > ids.length) {
+          this.truncateDestinations(ids.length);
+        }
+      }
+
+      this.hasUpstream = true;
+    },
+
+    processUpdates: function(data) {
+      this.processMessages(data.messages);
+      this.processDestinations(data.destinations);
+    },
+
+    start: function(args) {
+      var self = this;
+
+      var vanadiumWrapper = args.vanadiumWrapper;
+      var identity = args.identity;
+
+      var sbName = queryString.parse(location.search).syncbase || 4000;
+      if ($.isNumeric(sbName)) {
+        sbName = '/localhost:' + sbName;
+      }
+
+      var startSyncbase = vanadiumWrapper
+        .syncbase(sbName)
+        .then(function(syncbase) {
+          syncbase.onError.add(self.onError);
+          syncbase.onUpdate.add(self.processUpdates);
+
+          /* TODO(rosswang): Once Vanadium supports global sync-group admin
+           * creation, remove this. For now, use the first local SyncBase
+           * instance to administrate. */
+          var sgAdmin = vanadium.naming.join(
+            identity.mountNames.user, 'sgadmin');
+          return vanadiumWrapper.mount(sgAdmin, sbName,
+              vanadiumWrapper.multiMount.FAIL)
+            .then(function() {
+              var sg = syncbase.syncGroup(sgAdmin, 'trip');
+
+              var spec = sg.buildSpec(
+                [''],
+                [vanadium.naming.join(identity.mountNames.user, 'sgmt')]
+              );
+
+              /* TODO(rosswang): Right now, duplicate SyncBase creates on
+               * different SyncBase instances results in siloed SyncGroups.
+               * Revisit this logic once it merges properly. */
+              return sg.joinOrCreate(spec);
+            })
+            .then(function() {
+              return syncbase;
+            });
+        });
+
+      return Promise.all([
+        vanadiumWrapper.server(
+          vanadium.naming.join(identity.mountNames.device, 'rpc'), this.server),
+        startSyncbase
+      ]).then(function(values) {
+        return {
+          server: values[0],
+          syncbase: values[1]
+        };
+      });
     }
   },
 
+  constants: [ 'startup' ],
   events: {
+    /**
+     * @param newSize
+     */
+    onTruncateDestinations: '',
+
+    /**
+     * @param i
+     * @param place
+     */
+    onPlaceChange: '',
+
     onError: 'memory',
+
     /**
      * @param messages array of {content, timestamp} pair objects.
      */
     onMessages: '',
-    onPlanUpdate: '',
+
     onStatusUpdate: ''
   },
 
-  init: function() {
-    this.tripPlan = [];
+  /**
+   * @param promise a promise that produces { mountName, vanadiumWrapper }.
+   * @mapsDependencies an object with the following keys:
+   *  maps
+   *  placesService
+   */
+  init: function(promise, mapsDependencies) {
+    var self = this;
+
+    this.mapsDeps = mapsDependencies;
+
     this.tripStatus = {};
     this.messages = {};
+    this.destRecords = [];
 
     this.server = new vdlTravel.TravelSync();
+    this.startup = promise.then(this.start);
 
-    var travelSync = this;
-    this.server.get = function(ctx, serverCall) {
-      return {
-        Plan: travelSync.tripPlan,
-        Status: travelSync.tripStatus
-      };
-    };
-
-    this.server.updatePlan = function(ctx, serverCall, plan, message) {
-      travelSync.tripPlan = plan;
-      travelSync.onPlanUpdate(plan);
-      travelSync.onMessage(message);
-    };
-
-    this.server.updateStatus = function(ctx, serverCall, status) {
-      travelSync.tripStatus = status;
-      travelSync.onStatusUpdate(status);
+    this.handleDestinationPlaceChange = function() {
+      self.updateDestinationPlace(this);
     };
   }
 });
diff --git a/src/vanadium-wrapper/index.js b/src/vanadium-wrapper/index.js
index d6f52c4..ba2a3a0 100644
--- a/src/vanadium-wrapper/index.js
+++ b/src/vanadium-wrapper/index.js
@@ -7,10 +7,21 @@
 
 var SyncbaseWrapper = require('./syncbase-wrapper');
 
+var NAME_TTL = 5000;
+var NAME_REFRESH = 2500;
+
 var VanadiumWrapper = defineClass({
-  init: function(runtime) {
-    this.runtime = runtime;
-    runtime.on('crash', this.onCrash);
+  statics: {
+    multiMount: {
+      ADD: 0,
+      REPLACE: 1,
+      /**
+       * TODO(rosswang): This mode is not perfect/not entirely supported and is
+       * a hack to allow somewhat deterministic syncbase admin mounting before
+       * mount tables can spin up their own instances.
+       */
+      FAIL: 2
+    }
   },
 
   publics: {
@@ -18,6 +29,54 @@
       return this.runtime.accountName;
     },
 
+    mount: function(name, server, multiMount) {
+      var self = this;
+
+      multiMount = multiMount || this.multiMount.ADD;
+
+      function refreshName() {
+        var p;
+
+        var context = self.runtime.getContext();
+        var namespace = self.runtime.namespace();
+
+        function mount(replaceMount) {
+          return namespace.mount(context, name, server, NAME_TTL, replaceMount);
+        }
+
+        if (multiMount === self.multiMount.FAIL) {
+          /* TODO(rosswang): of course this isn't perfect; this is a hack to be
+           * removed once we no longer need to mount an admin syncbase
+           * instance. */
+
+
+          p = namespace.resolve(context, name)
+            .then(function(addresses) {
+              if (addresses[0] === server) {
+                return mount(true);
+              }
+            }, function(err) {
+              if (err.id === 'v.io/v23/naming.nameDoesntExist') {
+                return mount(true);
+              } else {
+                throw err;
+              }
+            });
+        } else {
+          p = mount(multiMount === self.multiMount.REPLACE);
+        }
+
+        p.catch(self.onError);
+
+        /* TODO(rosswang): should refresh intervals start here after initiation
+         * or after ack? */
+        setTimeout(refreshName, NAME_REFRESH);
+
+        return p;
+      }
+      return refreshName();
+    },
+
     /**
      * @param endpoint Vanadium name
      * @returns a promise resolving to a client or rejecting with an error.
@@ -45,11 +104,19 @@
   },
 
   events: {
-    onCrash: 'memory'
+    onCrash: 'memory',
+    onError: 'memory'
+  },
+
+  init: function(runtime) {
+    this.runtime = runtime;
+    runtime.on('crash', this.onCrash);
   }
 });
 
 module.exports = {
+  multiMount: VanadiumWrapper.multiMount,
+
   /**
    * @param vanadium optional vanadium override
    * @returns a promise resolving to a VanadiumWrapper or rejecting with an
diff --git a/src/vanadium-wrapper/syncbase-wrapper.js b/src/vanadium-wrapper/syncbase-wrapper.js
index 5fd994f..0c2c3e7 100644
--- a/src/vanadium-wrapper/syncbase-wrapper.js
+++ b/src/vanadium-wrapper/syncbase-wrapper.js
@@ -2,11 +2,16 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+require('es6-shim');
+
 var promisify = require('es6-promisify');
 var syncbase = require('syncbase');
+var vanadium = require('vanadium');
 
 var defineClass = require('../util/define-class');
 
+var debug = require('../debug');
+
 /**
  * Create app, db, and table structure in Syncbase.
  */
@@ -35,9 +40,16 @@
     .catch(nonfatals);
 }
 
+function joinKey(key) {
+  return key.join('.');
+}
+
 /**
  * Translate Syncbase hierarchical keys to object structure for easier
  * processing. '.' is chosen as the separator; '/' is reserved in Syncbase.
+ *
+ * It might be ideal to have the separator configurable, but certain separators
+ * need regex escaping.
  */
 function recursiveSet(root, key, value) {
   var matches = /\.?([^\.]*)(.*)/.exec(key);
@@ -48,13 +60,23 @@
     var child = root[member];
     if (!child) {
       child = root[member] = {};
+    } else if (typeof child !== 'object') {
+      child = root[member] = { _: child };
     }
+
     recursiveSet(child, remaining, value);
   } else {
-    root[member] = value;
+    var obj = root[member];
+    if (obj) {
+      obj._ = value;
+    } else {
+      root[member] = value;
+    }
   }
 }
 
+var SG_MEMBER_INFO = new syncbase.nosql.SyncGroupMemberInfo();
+
 var SyncbaseWrapper = defineClass({
   statics: {
     start: function(context, mountName) {
@@ -69,28 +91,253 @@
   },
 
   publics: {
+    /**
+     * @param seq a function executing the batch operations, receiving as its
+     *  `this` context and first parameter the batch operation methods
+     *  (put, delete), each of which returns a promise. The callback must return
+     *  the overarching promise.
+     */
+    batch: function(fn){
+      var self = this;
+      var opts = new syncbase.nosql.BatchOptions();
+
+      return this.manageWrite(this.runInBatch(this.context, this.db, opts,
+        function(db, cb) {
+          var t = db.table('t');
+          var putToSyncbase = promisify(t.put.bind(t));
+          var deleteFromSyncbase = promisify(t.delete.bind(t));
+
+          var ops = {
+            put: function(k, v) {
+              return self.standardPut(putToSyncbase, k, v);
+            },
+            delete: function(k) {
+              return self.standardDelete(deleteFromSyncbase, k);
+            }
+          };
+
+          fn.call(ops, ops).then(function(result) {
+            return cb(null, result);
+          }, function(err) {
+            return cb(err);
+          });
+        }));
+    },
+
+    /**
+     * @param k array of key elements
+     * @param v serialized value
+     */
+    put: function(k, v) {
+      return this.manageWrite(this.standardPut(this.putToSyncbase, k, v));
+    },
+
+    delete: function(k) {
+      return this.manageWrite(this.standardDelete(this.deleteFromSyncbase, k));
+    },
+
+    /**
+     * Since I/O is asynchronous, sparse, and fast, let's avoid concurrency/
+     * merging with the local syncbase instance by only starting a refresh if
+     * no writes are in progress and the refresh finishes before any new writes
+     * have started. Client watch should help make this better. In any case if
+     * this becomes starved, we can be smarter by being sensitive to keys being
+     * updated at any given time.
+     *
+     * We can also get around this problem by restructuring the data flow to
+     * be unidirectional with the local Syncbase as the authority, though that
+     * introduces (hopefully negligible) latency and complicates forked response
+     * on user input for the same data.
+     *
+     * @returns a void promise for this refresh
+     */
     refresh: function() {
       var self = this;
-      var isHeader = true;
 
-      var query = 'select k, v from t';
-      var newData = {};
-      this.db.exec(this.context, query, function(err) {
-        if (err) {
-          self.onError(err);
-        } else {
-          self.data = newData;
-          self.onUpdate(newData);
-        }
-      }).on('data', function(row) {
-        if (isHeader) {
-          isHeader = false;
-        } else {
-          recursiveSet(newData, row[0], row[1]);
-        }
-      }).on('error', function(err) {
-        self.onError(err);
+      var current = this.pull.current;
+      if (!current) {
+        current = this.pull.current = this.pull().then(function(v) {
+            self.pull.current = null;
+            return v;
+          }, function(err) {
+            self.pull.current = null;
+            throw err;
+          });
+      }
+
+      return current;
+    },
+
+    syncGroup: function(sgAdmin, name) {
+      var self = this;
+
+      name = vanadium.naming.join(sgAdmin, '$sync', name);
+      var sg = this.db.syncGroup(name);
+
+      //syncgroup-promisified
+      var sgp;
+
+      function chainable(cb) {
+        return function(err) {
+          cb(err, sgp);
+        };
+      }
+
+      var create = promisify(function(spec, cb) {
+        debug.log('Syncbase: create syncgroup ' + name);
+        sg.create(self.context, spec, SG_MEMBER_INFO, chainable(cb));
       });
+
+      var join = promisify(function(cb) {
+        debug.log('Syncbase: join syncgroup ' + name);
+        sg.join(self.context, SG_MEMBER_INFO, chainable(cb));
+      });
+
+      var setSpec = promisify(function(spec, cb) {
+          sg.setSpec(self.context, spec, '', chainable(cb));
+      });
+
+      //be explicit about arg lists because promisify is sensitive to extra args
+      sgp = {
+        buildSpec: function(prefixes, mountTables) {
+          return new syncbase.nosql.SyncGroupSpec({
+            perms: new Map([
+              ['Admin', {in: ['...']}],
+              ['Read', {in: ['...']}],
+              ['Write', {in: ['...']}],
+              ['Resolve', {in: ['...']}],
+              ['Debug', {in: ['...']}]
+            ]),
+            prefixes: prefixes.map(function(p) { return 't:' + p; }),
+            mountTables: mountTables
+          });
+        },
+
+        create: function(spec) { return create(spec); },
+        join: function() { return join(); },
+        setSpec: function(spec) { return setSpec(spec); },
+
+        createOrJoin: function(spec) {
+          return sgp.create(spec)
+            .catch(function(err) {
+              if (err.id === 'v.io/v23/verror.Exist') {
+                debug.log('Syncbase: syncgroup ' + name + ' already exists.');
+                return sgp.join()
+                  .then(function() {
+                    return sgp.setSpec(spec);
+                  });
+              } else {
+                throw err;
+              }
+            });
+        },
+
+        joinOrCreate: function(spec) {
+          return sgp.join()
+            .then(function() {
+              return sgp.setSpec(spec);
+            }, function(err) {
+              if (err.id === 'v.io/v23/verror.NoExist') {
+                debug.log('Syncbase: syncgroup ' + name + ' does not exist.');
+                return sgp.createOrJoin(spec);
+              } else {
+                throw err;
+              }
+            });
+        }
+      };
+
+      return sgp;
+    }
+  },
+
+  privates: {
+    manageWrite: function(promise) {
+      var writes = this.writes;
+
+      this.dirty = true;
+      writes.add(promise);
+
+      return promise.then(function(v) {
+        writes.delete(promise);
+        return v;
+      }, function(err) {
+        writes.delete(promise);
+        throw err;
+      });
+    },
+
+    standardPut: function(fn, k, v) {
+      k = joinKey(k);
+      debug.log('Syncbase: put ' + k + ' = ' + v);
+      return fn(this.context, k, v);
+    },
+
+    standardDelete: function(fn, k) {
+      k = joinKey(k);
+      debug.log('Syncbase: delete ' + k);
+      return fn(this.context, syncbase.nosql.rowrange.prefix(k));
+    },
+
+    /**
+     * @see refresh
+     */
+    pull: function() {
+      var self = this;
+
+      if (this.writes.size) {
+        debug.log('Syncbase: deferring refresh due to writes in progress');
+        return Promise.all(this.writes)
+          .then(this.pull, this.pull);
+
+      } else {
+        this.dirty = false;
+
+        return new Promise(function(resolve, reject) {
+          var newData = {};
+          var abort = false;
+
+          var isHeader = true;
+
+          self.db.exec(self.context, 'select k, v from t', function(err) {
+            if (err) {
+              reject(err);
+            } else if (abort) {
+              //no-op; promise has already been resolved.
+            } else if (self.dirty) {
+              debug.log('Syncbase: aborting refresh due to writes');
+              resolve(self.pull()); //try/wait for idle again
+            } else {
+              self.onUpdate(newData);
+              resolve();
+            }
+          }).on('data', function(row) {
+            if (isHeader) {
+              isHeader = false;
+              return;
+            }
+
+            if (abort) {
+              //no-op
+            } else if (self.dirty) {
+              abort = true;
+              debug.log('Syncbase: aborting refresh due to writes');
+              resolve(self.pull()); //try/wait for idle again
+              /* It would be nice to abort this stream for real, but we can't.
+               * Leave this handler attached but no-oping to drain the stream.
+               */
+            } else {
+              recursiveSet(newData, row[0], row[1]);
+            }
+          }).on('error', reject);
+        }).catch(function(err) {
+          if (err.id === 'v.io/v23/verror.Internal') {
+            console.error(err);
+          } else {
+            throw err;
+          }
+        });
+      }
     }
   },
 
@@ -103,15 +350,26 @@
     var self = this;
     this.context = context;
     this.db = db;
-    this.data = {};
+    this.t = db.table('t');
+
+    this.writes = new Set();
+
+    this.runInBatch = promisify(syncbase.nosql.runInBatch);
+    this.putToSyncbase = promisify(this.t.put.bind(this.t));
+    this.deleteFromSyncbase = promisify(this.t.delete.bind(this.t));
 
     // Start the watch loop to periodically poll for changes from sync.
     // TODO(rosswang): Remove this once we have client watch.
     this.watchLoop = function() {
-      self.refresh();
+      if (!self.pull.current) {
+        self.refresh().catch(self.onError);
+      }
       setTimeout(self.watchLoop, 500);
     };
-    process.nextTick(self.watchLoop);
+    // TODO(rosswang): Right now sync fails if the initial db has a conflict, so
+    // for now add a delay so that sync happens before we start db actions
+    //process.nextTick(self.watchLoop);
+    setTimeout(self.watchLoop, 2000);
   }
 });
 
diff --git a/test/components/map.js b/test/components/map-widget.js
similarity index 84%
rename from test/components/map.js
rename to test/components/map-widget.js
index 340e935..a5beae1 100644
--- a/test/components/map.js
+++ b/test/components/map-widget.js
@@ -4,14 +4,14 @@
 
 var test = require('tape');
 
-var Map = require('../../src/components/map');
+var MapWidget = require('../../src/components/map-widget');
 var mockMaps = require('../../mocks/google-maps');
 
 test('instantiation', function(t) {
   t.doesNotThrow(function() {
     //instantiation smoke test
     /* jshint -W031 */
-    new Map({
+    new MapWidget({
       maps: mockMaps
     });
     /* jshint +W031 */
diff --git a/test/travelsync.js b/test/travelsync.js
index f86b753..7721166 100644
--- a/test/travelsync.js
+++ b/test/travelsync.js
@@ -4,9 +4,11 @@
 
 var test = require('tape');
 
+var Deferred = require('vanadium/src/lib/deferred');
+
 var TravelSync = require('../src/travelsync');
 
 test('init', function(t) {
-  t.ok(new TravelSync(), 'initializes');
+  t.ok(new TravelSync(new Deferred().promise), 'initializes');
   t.end();
 });
\ No newline at end of file
diff --git a/tools/start_services.sh b/tools/start_services.sh
index 6432e60..1247ac5 100644
--- a/tools/start_services.sh
+++ b/tools/start_services.sh
@@ -26,12 +26,10 @@
 }
 main() {
   local -r TMP=tmp
-  local -r PORT=${PORT-4000}
+  local -r PORT=${port-4000}
   local -r MOUNTTABLED_ADDR=":$((PORT+1))"
-  local -r SYNCBASED_ADDR=":$((PORT+2))"
+  local -r SYNCBASED_ADDR=":$((PORT))"
   mkdir -p $TMP
-  # TODO(rosswang): Run mounttabled and syncbased each with its own blessing
-  # extension.
   ${V23_ROOT}/release/go/bin/mounttabled \
     --v23.tcp.address=${MOUNTTABLED_ADDR} \
     --v23.credentials=${TMP}/creds &