Rough unidirectional timeline casting

Change-Id: I317a6afc4a2bd0d0482977e82f51711bd7779b10
diff --git a/.gitignore b/.gitignore
index 36dd53c..68dbe6f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,5 +2,4 @@
 /ifc
 /node_modules
 /server-root
-/bin
 /tmp
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 49f09c4..719e631 100644
--- a/Makefile
+++ b/Makefile
@@ -9,12 +9,12 @@
 server_static := $(patsubst src/static/%,server-root/%,$(wildcard src/static/*))
 tests := $(patsubst %.js,%,$(shell find test -name "*.js"))
 
-out_dirs := ifc server-root node_modules bin
+out_dirs := ifc server-root node_modules
 
 .DELETE_ON_ERROR:
 
 .PHONY: all
-all: static js bin
+all: static js
 	@true
 
 .PHONY: static
@@ -23,11 +23,10 @@
 .PHONY: js
 js: server-root/bundle.js
 
-bin:
-	@v23 go build -a -o $@/syncbased v.io/syncbase/x/ref/services/syncbase/syncbased
-	@touch $@
+.PHONY: ifc
+ifc: ifc/index.js
 
-ifc: src/ifc/*
+ifc/index.js: src/ifc/*
 	@VDLPATH=src vdl generate -lang=javascript -js-out-dir=. ifc
 
 node_modules: package.json
@@ -41,7 +40,7 @@
 server-root:
 	@mkdir server-root
 
-server-root/bundle.js: ifc node_modules $(js_files) | server-root
+server-root/bundle.js: ifc/index.js node_modules $(js_files) | server-root
 	browserify --debug src/index.js 1> $@
 
 $(server_static): server-root/%: src/static/% | server-root
@@ -56,7 +55,7 @@
 test: lint $(tests)
 
 .PHONY: $(tests)
-$(tests): test/%: test/%.js test/* mocks/* ifc node_modules $(js_files)
+$(tests): test/%: test/%.js test/* mocks/* ifc/index.js node_modules $(js_files)
 	@tape $<
 
 .PHONY: start
@@ -71,7 +70,7 @@
 	@principal seekblessings --v23.credentials tmp/creds/$(creds)
 
 .PHONY: syncbase
-syncbase: bin
+syncbase:
 	@bash ./tools/start_services.sh
 
 .PHONY: clean-all
diff --git a/mocks/google-maps.js b/mocks/google-maps.js
index 65639be..0a70759 100644
--- a/mocks/google-maps.js
+++ b/mocks/google-maps.js
@@ -200,7 +200,8 @@
 
   events: {
     'bounds_changed': 'public',
-    click: 'public'
+    click: 'public',
+    resize: 'public'
   },
 
   init: function(canvas) {
@@ -318,6 +319,13 @@
   }
 });
 
+function validateEvent(instance, eventName) {
+  //approximate check; just a sanity check during testing
+  if (typeof instance[eventName] !== 'function') {
+    throw instance + ' does not mock event ' + eventName;
+  }
+}
+
 maps = {
   ControlPosition: ControlPosition,
   DirectionsRenderer: DirectionsRenderer,
@@ -334,13 +342,11 @@
 
   event: {
     addListener: function(instance, eventName, handler){
-      if (eventName in instance) {
-        instance[eventName].add(handler);
-      } else {
-        throw instance + ' does not mock event ' + eventName;
-      }
+      validateEvent(instance, eventName);
+      instance[eventName].add(handler);
     },
     trigger: function(instance, eventName) {
+      validateEvent(instance, eventName);
       instance[eventName].apply(instance,
         Array.prototype.slice.call(arguments, 2));
     }
diff --git a/src/casting-manager.js b/src/casting-manager.js
index 53b1b1d..9bf1c73 100644
--- a/src/casting-manager.js
+++ b/src/casting-manager.js
@@ -132,32 +132,36 @@
       var self = this;
 
       var direction = getGestureDirection(v);
-      var related = this.getRelatedDevices(direction);
-      if (related.size === 1) {
-        related.forEach(function(deviceName, owner) {
-          self.cast(owner, deviceName, spec).catch(self.onError);
-        });
-      } else {
-        var unknown = this.travelSync.getUnconnectedCastTargets();
-
-        if (related.size === 0 && unknown.size === 1) {
-          unknown.forEach(function(deviceName, owner) {
-            Promise.all([
-              self.cast(owner, deviceName, spec),
-              self.travelSync.relateDevice(owner, deviceName, {
-                direction: direction,
-                magnitude: DeviceSync.NEAR
-              })
-            ]).catch(self.onError);
+      if (direction) {
+        var related = this.getRelatedDevices(direction);
+        if (related.size === 1) {
+          // Use forEach for singleton multimap entry extraction.
+          related.forEach(function(deviceName, owner) {
+            self.cast(owner, deviceName, spec).catch(self.onError);
           });
         } else {
-          var all = this.travelSync.getPossibleCastTargets();
-          var other = difference(all, related);
+          var unknown = this.travelSync.getUnconnectedCastTargets();
 
-          if (related.size > 0 || unknown.size > 0 || other.size > 0) {
-            this.onAmbiguousCast(related, unknown, other);
+          if (related.size === 0 && unknown.size === 1) {
+            // Use forEach for singleton multimap entry extraction.
+            unknown.forEach(function(deviceName, owner) {
+              Promise.all([
+                self.cast(owner, deviceName, spec),
+                self.travelSync.relateDevice(owner, deviceName, {
+                  direction: direction,
+                  magnitude: DeviceSync.NEAR
+                })
+              ]).catch(self.onError);
+            });
           } else {
-            this.onNoNearbyDevices();
+            var all = this.travelSync.getPossibleCastTargets();
+            var other = difference(all, related);
+
+            if (related.size > 0 || unknown.size > 0 || other.size > 0) {
+              this.onAmbiguousCast(related, unknown, other);
+            } else {
+              this.onNoNearbyDevices();
+            }
           }
         }
       }
@@ -168,13 +172,18 @@
 
       return this.travelSync.cast(targetOwner, targetDeviceName, spec)
         .then(function() {
-          self.onCast(spec);
+          self.onSendCast(targetOwner, targetDeviceName, spec);
         }, this.onError);
     }
   },
 
   events: {
-    onCast: '',
+    /**
+     * @param targetOwner target device owner
+     * @param targetDeviceName target device name
+     * @param spec the cast spec, as given to makeCastable's opts.
+     */
+    onSendCast: '',
     /**
      * @param related owner => device multimap of related cast candidates
      * @param unknown owner => device multimap of unconnected cast candidates
diff --git a/src/components/destination-search.js b/src/components/destination-search.js
index 2dffcc9..e5e64c4 100644
--- a/src/components/destination-search.js
+++ b/src/components/destination-search.js
@@ -2,81 +2,122 @@
 // 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 debug = require('../debug');
+var Place = require('../place');
+
 var DestinationSearch = defineClass({
   publics: {
     clear: function() {
-      this.setPlace(null);
+      var async = this.setPlace(null);
       this.$searchBox.prop('value', '');
+      return async;
     },
 
     enable: function() {
       this.$searchBox.removeAttr('disabled');
+      return Promise.resolve();
     },
 
     disable: function() {
       this.$searchBox.attr('disabled', 'disabled');
+      return Promise.resolve();
     },
 
     focus: function() {
       this.$.find('input:visible').focus();
+      return Promise.resolve();
     },
 
     hasFocus: function() {
-      return this.$.find(':focus').length > 0;
+      return Promise.resolve(this.$.find(':focus').length > 0);
     },
 
     setSearchBounds: function(bounds) {
       this.searchBox.setBounds(bounds);
+      return Promise.resolve();
     },
 
     select: function() {
       this.$.addClass('selected');
+      return Promise.resolve();
     },
 
     deselect: function() {
-      if (this.isSelected()) {
-        this.$.removeClass('selected');
-        this.onDeselect();
-      }
+      var self = this;
+      return this.isSelected().then(function(isSelected) {
+        if (isSelected) {
+          self.$.removeClass('selected');
+          self.onDeselect();
+        }
+      });
     },
 
     isSelected: function() {
-      return this.$.hasClass('selected');
+      return Promise.resolve(this.$.hasClass('selected'));
     },
 
     getPlace: function() {
-      return this.place;
+      return Promise.resolve(this.place);
     },
 
     setPlace: function(place) {
+      var self = this;
       var prev = this.place;
-      if (prev !== place) {
+      if (!Place.equal(prev, place)) {
         this.place = place;
         this.setAutocomplete(!place);
 
         var newValue;
         if (place) {
-          newValue = place.getSingleLine();
-        } else if (!this.hasFocus()) {
-          newValue = '';
-        }
-        if (newValue !== undefined) {
-          this.$searchBox.prop('value', newValue);
+          newValue = Promise.resolve(place.getSingleLine());
+        } else {
+          newValue = this.hasFocus().then(function(hasFocus) {
+            /* We only want to clear when we don't have focus because if we have
+             * focus, we're actively editing the text even if it may be
+             * presently invalid. */
+            if (!hasFocus) {
+              return '';
+            }
+          });
         }
 
-        this.onPlaceChange(place, prev);
+        /* Since making all timeline UI asynchronous, we introduce a race
+         * condition where a destination deselect starts a chain of events to
+         * clear a place, then a reselect starts a chain of events to set it,
+         * but since the clear includes an asynchronous focus check, it takes
+         * longer to complete and can overwrite the effect of the set. So, we
+         * need to queue the aftereffects. */
+
+        this.setValueInProgress = this.setValueInProgress
+          .catch($.noop)
+          .then(function() {
+            return newValue;
+          })
+          .then(function(newValue) {
+            if (newValue !== undefined) {
+              self.$searchBox.prop('value', newValue);
+            }
+
+            self.onPlaceChange(place, prev);
+          });
+        return this.setValueInProgress;
+      } else {
+        return Promise.resolve();
       }
     },
 
     setPlaceholder: function(placeholder) {
       this.$searchBox.attr('placeholder', placeholder);
+      return Promise.resolve();
     },
 
     getValue: function() {
-      return this.$searchBox.prop('value');
+      return Promise.resolve(this.$searchBox.prop('value'));
     }
   },
 
@@ -123,13 +164,15 @@
 
     inputKey: function(e) {
       if (e.which === 13) {
-        this.onSubmit(this.getValue());
+        this.getValue().then(this.onSubmit).catch(debug.log);
       }
       e.stopPropagation();
     }
   },
 
   events: [
+    'onDeselect',
+
     /**
      * @param event jQuery Event object for text box focus event.
      */
@@ -146,8 +189,6 @@
      */
     'onSearch',
 
-    '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,
@@ -163,6 +204,8 @@
   init: function(maps) {
     var self = this;
 
+    this.setValueInProgress = Promise.resolve();
+
     var $searchBox = $.merge($('<input>'), $('<input>'))
       .attr('type', 'text')
       //to make dummy box consistent with search
@@ -185,7 +228,9 @@
     this.autocomplete = true;
 
     maps.event.addListener(this.searchBox, 'places_changed', function() {
-      self.onSearch(self.searchBox.getPlaces());
+      self.onSearch(self.searchBox.getPlaces().map(function(result) {
+        return new Place(result);
+      }));
     });
   }
 });
diff --git a/src/components/map-widget.js b/src/components/map-widget.js
index 6659ad3..219b74f 100644
--- a/src/components/map-widget.js
+++ b/src/components/map-widget.js
@@ -126,7 +126,7 @@
       this.closeActiveInfoWindow();
 
       this.fitGeoms(results.map(function(result) {
-        return result.geometry;
+        return result.getGeometry();
       }));
 
       var dest = this.selectedDestination;
@@ -134,11 +134,9 @@
         /* It would be nice if we could distinguish between an autocomplete
          * 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]));
+        dest.setPlace(results[0]);
       } else if (results.length > 0) {
-        $.each(results, function(i, result) {
-          var place = new Place(result);
-
+        $.each(results, function(i, place) {
           var marker = self.getOrCreateMarker(place, SEARCH_CLIENT,
             DestinationMarker.color.RED, null, false);
           self.searchMarkers.push(marker);
diff --git a/src/components/timeline-client.js b/src/components/timeline-client.js
new file mode 100644
index 0000000..fe5ced4
--- /dev/null
+++ b/src/components/timeline-client.js
@@ -0,0 +1,194 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var defineClass = require('../util/define-class');
+
+var ifcx = require('../ifc/conversions');
+
+var destDefs = {
+  getPlace: function() {
+    var self = this;
+    return this.outer.service.getDestinationPlace(this.outer.context, this.id)
+      .then(function(place) {
+        return ifcx.toPlace(self.outer.dependencies, place);
+      });
+  },
+
+  setPlace: function(place) {
+    return this.outer.service.setDestinationPlace(this.outer.context, this.id,
+      ifcx.fromPlace(place) || null);
+  },
+
+  setPlaceholder: function(placeholder) {
+    return this.outer.service.setDestinationPlaceholder(this.outer.context,
+      this.id, placeholder);
+  },
+
+  setSearchBounds: function(bounds) {
+    return this.outer.service.setDestinationSearchBounds(this.outer.context,
+      this.id, ifcx.fromLatLngBounds(bounds));
+  }
+};
+
+function bindDestinationMethod(localMethod, remoteMethod) {
+  destDefs[localMethod] = function() {
+    return this.outer.service[remoteMethod](this.outer.context, this.id);
+  };
+}
+
+['clear', 'enable', 'disable', 'focus', 'select', 'deselect'].forEach(
+function(method) {
+  bindDestinationMethod(method, method + 'Destination');
+});
+
+bindDestinationMethod('hasFocus', 'destinationHasFocus');
+bindDestinationMethod('isSelected', 'isDestinationSelected');
+bindDestinationMethod('getValue', 'getDestinationValue');
+
+var TimelineClient = defineClass({
+  publics: {
+    disableAdd: function() {
+      return this.service.disableAdd(this.context);
+    },
+
+    enableAdd: function() {
+      return this.service.enableAdd(this.context);
+    },
+
+    add: function(i) {
+      return this.service.add(this.context, ifcx.box(i))
+        .then(this.getDestination);
+    },
+
+    get: function(i) {
+      return this.service.get(this.context, ifcx.box(i))
+        .then(this.getDestinationOrDestinations);
+    },
+
+    remove: function(i) {
+      return this.service.get(this.context, ifcx.box(i))
+        .then(this.getDestination);
+    },
+
+    setSearchBounds: function(bounds) {
+      return this.service.setSearchBounds(this.context,
+        ifcx.fromLatLngBounds(bounds));
+    }
+  },
+
+  privates: {
+    destinationClient: defineClass.innerClass({
+      publics: destDefs,
+
+      privates: {
+        /**
+         * @param localEventName the name of the event on the client object
+         * @param remoteEventName the name of the streaming API serving the
+         *  event on the remote server
+         * @param translateArgs a function taking the remote event data and
+         *  returning an array of arguments or returning a promise resolving to
+         *  an array of arguments to be passed to local event handlers.
+         */
+        bindEvent: function(localEventName, remoteEventName, translateArgs) {
+          var self = this;
+
+          var event = this.outer.service[remoteEventName]
+            (this.outer.context, this.id);
+          event.catch(this.outer.onError);
+          event.stream.on('error', this.outer.onError);
+          event.stream.on('data', function(e) {
+            Promise.resolve(translateArgs && translateArgs(e))
+              .then(function(args) {
+                self[localEventName].apply(self, args);
+              }).catch(self.outer.onError);
+          });
+        }
+      },
+
+      events: [
+        'onDeselect',
+        'onFocus',
+        'onPlaceChange',
+        'onSearch',
+        'onSubmit'
+      ],
+
+      init: function(id) {
+        var self = this;
+
+        this.id = id;
+
+        this.bindEvent('onDeselect', 'onDestinationDeselect');
+        this.bindEvent('onFocus', 'onDestinationFocus');
+        this.bindEvent('onPlaceChange', 'onDestinationPlaceChange',
+          function(e) {
+            return Promise.all([
+              ifcx.toPlace(self.outer.dependencies, e.place),
+              ifcx.toPlace(self.outer.dependencies, e.previous)
+            ]);
+          });
+        this.bindEvent('onSearch', 'onDestinationSearch', function(e) {
+          return Promise.all(e.places.map(function(place) {
+            return ifcx.toPlace(self.outer.dependencies, place);
+          })).then(function(places) {
+            return [places];
+          });
+        });
+        this.bindEvent('onSubmit', 'onDestinationSubmit', function(e) {
+          return [e.value];
+        });
+      }
+    }),
+
+    getDestination: function(id) {
+      if (!id) {
+        return null;
+      }
+
+      var destClient = this.destinations[id];
+      if (!destClient) {
+        destClient = this.destinations[id] = this.destinationClient(id);
+      }
+      return destClient;
+    },
+
+    getDestinationOrDestinations: function(idOrIds) {
+      return idOrIds.ids?
+        idOrIds.ids.map(this.getDestination) : this.getDestination(idOrIds.id);
+    },
+
+    bindEvent: function(eventName, translateArgs) {
+      var self = this;
+
+      var event = this.service[eventName](this.context);
+      event.catch(this.onError);
+      event.stream.on('error', this.onError);
+      event.stream.on('data', function(e) {
+        self[eventName].apply(self, translateArgs && translateArgs(e));
+      });
+    }
+  },
+
+  events: {
+    onAddClick: '',
+    onDestinationAdd: '',
+    onError: 'memory'
+  },
+
+  init: function(context, service, dependencies) {
+    var self = this;
+
+    this.context = context;
+    this.service = service;
+    this.dependencies = dependencies;
+    this.destinations = {};
+
+    this.bindEvent('onAddClick');
+    this.bindEvent('onDestinationAdd', function(e) {
+      return [ self.getDestination(e.id) ];
+    });
+  }
+});
+
+module.exports = TimelineClient;
diff --git a/src/components/timeline-server.js b/src/components/timeline-server.js
new file mode 100644
index 0000000..638baf1
--- /dev/null
+++ b/src/components/timeline-server.js
@@ -0,0 +1,219 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// 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 ifcx = require('../ifc/conversions');
+var uuid = require('uuid');
+
+var vdlTravel = require('../../ifc');
+
+function addEventStreamListener(context, event, eventFactory, $stream) {
+  return new Promise(function(resolve, reject) {
+    function listener() {
+      try {
+        var buffOk = $stream.write(eventFactory.apply(context, arguments), null,
+          function(err) {
+            if (err) {
+              reject(err);
+            }
+          });
+        if (!buffOk) {
+          reject('Event buffer full.');
+        }
+      } catch (err) {
+        reject(err);
+      }
+    }
+
+    event.add(listener);
+    $stream.on('end', function() {
+      event.remove(listener);
+      resolve();
+    });
+    $stream.on('error', reject);
+  });
+}
+
+function multiplexedEvent(eventName, eventFactory) {
+  return function(ctx, serverCall, id, $stream) {
+    var event = this.destinations[id][eventName];
+    function multiplexedFactory() {
+      // Prepend id to the arg list.
+      var args = [id];
+      Array.prototype.push.apply(args, arguments);
+      return eventFactory.apply(this, args);
+    }
+    return addEventStreamListener(this, event, multiplexedFactory, $stream);
+  };
+}
+
+function event(eventName, eventFactory) {
+  return function(ctx, serverCall, $stream) {
+    var event = this.timeline[eventName];
+    return addEventStreamListener(this, event, eventFactory, $stream);
+  };
+}
+
+/**
+ * We can't defineClass this because v23 checks length property of each member
+ * function, and we'd have to new Function each one to preserve that.
+ *
+ * @param timeline the timeline control to serve.
+ * @param dependencies {placesService, maps}
+ */
+function TimelineService(timeline, dependencies) {
+  this.timeline = timeline;
+  this.dependencies = dependencies;
+  this.destinations = {};
+  this.destinationIds = new Map();
+
+  this._identifyDestination = identifyDestination.bind(this);
+  this._identifyDestinationOrDestinations =
+    identifyDestinationOrDestinations.bind(this);
+
+  timeline.onDestinationAdd.add(this._identifyDestination);
+  timeline.get().then(function(destinations) {
+    destinations.forEach(this._identifyDestination);
+  });
+}
+
+TimelineService.prototype = new vdlTravel.Timeline();
+
+function identifyDestination(destination) {
+  if (!destination) {
+    return '';
+  }
+
+  var id = this.destinationIds.get(destination);
+  if (!id) {
+    id = uuid.v4();
+    this.destinations[id] = destination;
+    this.destinationIds.set(destination, id);
+  }
+  return id;
+}
+
+function identifyDestinationOrDestinations(polyd) {
+  return new vdlTravel.IdOrIds($.isArray(polyd)?
+    { ids: polyd.map(this._identifyDestination) } :
+    { id: this._identifyDestination(polyd) });
+}
+
+$.extend(TimelineService.prototype, {
+  destinationHasFocus: function(ctx, serverCall, id) {
+    return this.destinations[id].hasFocus();
+  },
+
+  isDestinationSelected: function(ctx, serverCall, id) {
+    return this.destinations[id].isSelected();
+  },
+
+  getDestinationPlace: function(ctx, serverCall, id) {
+    return this.destinations[id].getPlace().then(ifcx.fromPlace);
+  },
+
+  setDestinationPlace: function(ctx, serverCall, id, place) {
+    var destination = this.destinations[id];
+    return ifcx.toPlace(this.dependencies, place).then(destination.setPlace);
+  },
+
+  setDestinationPlaceholder: function(ctx, serverCall, id, placeholder) {
+    this.destinations[id].setPlaceholder(placeholder);
+  },
+
+  setDestinationSearchBounds: function(ctx, serverCall, id, bounds) {
+    this.destinations[id].setSearchBounds(
+      ifcx.toLatLngBounds(this.dependencies.maps, bounds));
+  },
+
+  getDestinationValue: function(ctx, serverCall, id) {
+    return this.destinations[id].getValue();
+  },
+
+  onDestinationDeselect: multiplexedEvent('onDeselect',
+    function(id) {
+      return new vdlTravel.MultiplexedEvent({
+        source: id
+      });
+    }),
+
+  onDestinationFocus: multiplexedEvent('onFocus',
+    function(id) {
+      return new vdlTravel.MultiplexedEvent({
+        source: id
+      });
+    }),
+
+  onDestinationPlaceChange: multiplexedEvent('onPlaceChange',
+    function(id, place, previous) {
+      return new vdlTravel.DestinationPlaceChangeEvent({
+        source: id,
+        place: ifcx.fromPlace(place),
+        previous: ifcx.fromPlace(previous)
+      });
+    }),
+
+  onDestinationSearch: multiplexedEvent('onSearch',
+    function(id, places) {
+      return new vdlTravel.DestinationSearchEvent({
+        source: id,
+        places: places.map(ifcx.fromPlace)
+      });
+    }),
+
+  onDestinationSubmit: multiplexedEvent('onSubmit',
+    function(id, value) {
+      return new vdlTravel.DestinationSubmitEvent({
+        source: id,
+        value: value
+      });
+    }),
+
+  setSearchBounds: function(ctx, serverCall, bounds) {
+    this.timeline.setSearchBounds(
+      ifcx.toLatLngBounds(this.dependencies.maps, bounds));
+  },
+
+  onAddClick: event('onAddClick',
+    function() {
+      return new vdlTravel.Event();
+    }),
+
+  onDestinationAdd: event('onDestinationAdd',
+    function(destinationSearch) {
+      return new vdlTravel.DestinationAddEvent({
+        id: this._identifyDestination(destinationSearch)
+      });
+    })
+});
+
+['clear', 'enable', 'disable', 'focus', 'select', 'deselect']
+.forEach(function(method) {
+  TimelineService.prototype[method + 'Destination'] =
+    function(ctx, serverCall, id) {
+      this.destinations[id][method]();
+    };
+});
+
+['disableAdd', 'enableAdd'].forEach(function(method) {
+  TimelineService.prototype[method] = function(ctx, serverCall) {
+    this.timeline[method]();
+  };
+});
+
+['add', 'remove'].forEach(function(method) {
+  TimelineService.prototype[method] = function(ctx, serverCall, i) {
+    return this.timeline[method](ifcx.unbox(i)).then(this._identifyDestination);
+  };
+});
+
+TimelineService.prototype.get = function(ctx, serverCall, i) {
+  return this.timeline.get(ifcx.unbox(i))
+    .then(this._identifyDestinationOrDestinations);
+};
+
+module.exports = TimelineService;
diff --git a/src/components/timeline.js b/src/components/timeline.js
index 10da699..31d2209 100644
--- a/src/components/timeline.js
+++ b/src/components/timeline.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.
 
+require ('es6-shim');
+
 var $ = require('../util/jquery');
 var defineClass = require('../util/define-class');
 
@@ -12,10 +14,12 @@
   publics: {
     disableAdd: function() {
       this.addButton.disable();
+      return Promise.resolve();
     },
 
     enableAdd: function() {
       this.addButton.enable();
+      return Promise.resolve();
     },
 
     add: function(i) {
@@ -30,16 +34,18 @@
         controls.splice(i, 0, destinationSearch);
       }
 
-      return destinationSearch;
+      this.onDestinationAdd(destinationSearch);
+
+      return Promise.resolve(destinationSearch);
     },
 
     get: function(i) {
       if (i === undefined) {
-        return this.controls.slice(0);
+        return Promise.resolve(this.controls.slice(0));
       } else if (i >= 0) {
-        return this.controls[i];
+        return Promise.resolve(this.controls[i]);
       } else if (i < 0) {
-        return this.controls[this.controls.length + i];
+        return Promise.resolve(this.controls[this.controls.length + i]);
       }
     },
 
@@ -54,12 +60,25 @@
       if (removed) {
         removed.$.remove();
       }
-      return removed;
+      return Promise.resolve(removed);
+    },
+
+    setSearchBounds: function(bounds) {
+      return Promise.all(this.controls.map(function(control) {
+        return control.setSearchBounds(bounds);
+      }));
     }
   },
 
   constants: [ '$' ],
-  events: [ 'onAddClick' ],
+  events: [
+    'onAddClick',
+
+    /**
+     * @param destinationSearch
+     */
+    'onDestinationAdd'
+  ],
 
   init: function(maps) {
     this.maps = maps;
diff --git a/src/destination.js b/src/destination.js
index 624149f..c794848 100644
--- a/src/destination.js
+++ b/src/destination.js
@@ -4,6 +4,8 @@
 
 var defineClass = require('./util/define-class');
 
+var Place = require('./place');
+
 var Destination = defineClass({
   publics: {
     getIndex: function() {
@@ -20,7 +22,7 @@
 
     setPlace: function(place) {
       var prev = this.place;
-      if (prev !== place) {
+      if (!Place.equal(prev, place)) {
         this.place = place;
         this.onPlaceChange(place, prev);
       }
diff --git a/src/ifc/conversions.js b/src/ifc/conversions.js
new file mode 100644
index 0000000..6cc210d
--- /dev/null
+++ b/src/ifc/conversions.js
@@ -0,0 +1,51 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+require('es6-shim');
+
+var vdlTravel = require('../../ifc');
+
+var Place = require('../place');
+
+module.exports = {
+  box: function(i) {
+    return i === undefined || i === null? i : new vdlTravel.Int16({ value: i });
+  },
+
+  unbox: function(ifc) {
+    return ifc && ifc.value;
+  },
+
+  toPlace: function(dependencies, ifc) {
+    return ifc? Place.fromObject(dependencies, ifc) : Promise.resolve();
+  },
+
+  fromPlace: function(place) {
+    return place && new vdlTravel.Place(place.toObject());
+  },
+
+  toLatLng: function(maps, ifc) {
+    return new maps.LatLng(ifc.lat, ifc.lng);
+  },
+
+  fromLatLng: function(latlng) {
+    return new vdlTravel.LatLng({
+      lat: latlng.lat(),
+      lng: latlng.lng()
+    });
+  },
+
+  toLatLngBounds: function(maps, ifc) {
+    return new maps.LatLngBounds(
+      module.exports.toLatLng(maps, ifc.sw),
+      module.exports.toLatLng(maps, ifc.ne));
+  },
+
+  fromLatLngBounds: function(bounds) {
+    return new vdlTravel.LatLngBounds({
+      sw: module.exports.fromLatLng(bounds.getSouthWest()),
+      ne: module.exports.fromLatLng(bounds.getNorthEast())
+    });
+  }
+};
\ No newline at end of file
diff --git a/src/ifc/timeline.vdl b/src/ifc/timeline.vdl
new file mode 100644
index 0000000..e45ec4b
--- /dev/null
+++ b/src/ifc/timeline.vdl
@@ -0,0 +1,37 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package ifc
+
+type Timeline interface {
+  ClearDestination(id Id) error
+  EnableDestination(id Id) error
+  DisableDestination(id Id) error
+  FocusDestination(id Id) error
+  DestinationHasFocus(id Id) (bool | error)
+  SelectDestination(id Id) error
+  DeselectDestination(id Id) error
+  IsDestinationSelected(id Id) (bool | error)
+  GetDestinationPlace(id Id) (?Place | error)
+  SetDestinationPlace(id Id, place ?Place) error
+  SetDestinationPlaceholder(id Id, placeholder string) error
+  SetDestinationSearchBounds(id Id, bounds LatLngBounds) error
+  GetDestinationValue(id Id) (string | error)
+
+  OnDestinationDeselect(id Id) stream<_, MultiplexedEvent> error
+  OnDestinationFocus(id Id) stream<_, MultiplexedEvent> error
+  OnDestinationPlaceChange(id Id) stream<_, DestinationPlaceChangeEvent> error
+  OnDestinationSearch(id Id) stream<_, DestinationSearchEvent> error
+  OnDestinationSubmit(id Id) stream<_, DestinationSubmitEvent> error
+
+  DisableAdd() error
+  EnableAdd() error
+  Add(i ?Int16) (Id | error)
+  Get(i ?Int16) (IdOrIds | error)
+  Remove(i ?Int16) (Id | error)
+  SetSearchBounds(bounds LatLngBounds) error
+
+  OnAddClick() stream<_, Event> error
+  OnDestinationAdd() stream<_, DestinationAddEvent> error
+}
diff --git a/src/ifc/types.vdl b/src/ifc/types.vdl
index 9dc1401..b171441 100644
--- a/src/ifc/types.vdl
+++ b/src/ifc/types.vdl
@@ -4,7 +4,61 @@
 
 package ifc
 
+type Id string
+type Latitude float32
+type Longitude float32
+
+type IdOrIds union {
+  Id Id
+  Ids []Id
+}
+
+// required for optional ints
+type Int16 struct {
+  Value int16
+}
+
 type CastSpec struct {
   PanelName string
 }
 
+type LatLng struct {
+  Lat Latitude
+  Lng Longitude
+}
+
+type LatLngBounds struct {
+  Sw LatLng
+  Ne LatLng
+}
+
+type Place struct {
+  PlaceId Id
+}
+
+type Event struct {
+}
+
+type MultiplexedEvent struct {
+  Source Id
+}
+
+type DestinationPlaceChangeEvent struct {
+  Source Id
+  Place ?Place
+  Previous ?Place
+}
+
+type DestinationSearchEvent struct {
+  Source Id
+  Places []Place
+}
+
+type DestinationSubmitEvent struct {
+  Source Id
+  Value string
+}
+
+type DestinationAddEvent struct {
+  Id Id
+}
diff --git a/src/naming.js b/src/naming.js
index 02a23ef..8d1be3b 100644
--- a/src/naming.js
+++ b/src/naming.js
@@ -18,8 +18,9 @@
   return vanadium.naming.join(appMount(username), deviceName);
 }
 
-function rpcMount(username, deviceName) {
-  return vanadium.naming.join(deviceMount(username, deviceName), 'rpc');
+function rpcMount(username, deviceName, serviceName) {
+  return vanadium.naming.join(deviceMount(username, deviceName),
+    serviceName || 'rpc');
 }
 
 function mountNames(id) {
@@ -27,7 +28,10 @@
     user: userMount(id.username),
     app: appMount(id.username),
     device: deviceMount(id.username, id.deviceName),
-    rpc: rpcMount(id.username, id.deviceName)
+    rpc: rpcMount(id.username, id.deviceName),
+    rpcMount: function(serviceName) {
+      return rpcMount(id.username, id.deviceName, serviceName);
+    }
   };
 }
 
diff --git a/src/place.js b/src/place.js
index 5096a9d..cc93922 100644
--- a/src/place.js
+++ b/src/place.js
@@ -2,28 +2,34 @@
 // 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');
+require('es6-shim');
 
 var defineClass = require('./util/define-class');
 
 var Place = defineClass({
   statics: {
+    /**
+     * @param dependencies {placesService, maps}
+     * @param obj the plain object representation of the place
+     */
     fromObject: function(dependencies, obj) {
-      var async = new Deferred();
+      return new Promise(function(resolve, reject) {
+        if (obj.placeId) {
+          dependencies.placesService.getDetails(obj, function(place, status) {
+            if (status === dependencies.maps.places.PlacesServiceStatus.OK) {
+              resolve(new Place(place));
+            } else {
+              reject(status);
+            }
+          });
+        } else {
+          reject('Deserialization not supported.'); //TODO(rosswang)
+        }
+      });
+    },
 
-      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;
+    equal: function(a, b) {
+      return a === b || a && b && a.toKey() === b.toKey();
     }
   },
 
diff --git a/src/strings.js b/src/strings.js
index 6f4ac8d..8327e1f 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -26,6 +26,9 @@
     add: function(object) {
       return 'Add ' + object.toLowerCase();
     },
+    notCastable: function(feature) {
+      return 'The ' + feature + ' feature is not castable.';
+    },
     castingTooltip: 'To cast a panel to a nearby device, middle-click and ' +
       'drag (or left-right-click and drag) the panel towards the target ' +
       'device.',
diff --git a/src/sync-util/device-sync.js b/src/sync-util/device-sync.js
index 4245782..a126179 100644
--- a/src/sync-util/device-sync.js
+++ b/src/sync-util/device-sync.js
@@ -72,6 +72,20 @@
   }
 }
 
+var RE = 6.371e6;
+
+function cartesian(geo) {
+  var lat = geo.latitude * Math.PI / 180;
+  var lng = geo.longitude * Math.PI / 180;
+  var planeFactor = Math.cos(lng);
+
+  return {
+    x: RE * Math.cos(lat) * planeFactor,
+    y: RE * Math.sin(lat) * planeFactor,
+    z: RE * Math.sin(lng)
+  };
+}
+
 var DeviceSync = defineClass({
   statics: {
     LEFT: LEFT,
diff --git a/src/travel.js b/src/travel.js
index 55c16ca..820bab1 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -16,6 +16,8 @@
 var Messages = require('./components/messages');
 var Message = require('./components/message');
 var Timeline = require('./components/timeline');
+var TimelineClient = require('./components/timeline-client');
+var TimelineService = require('./components/timeline-server');
 
 var CastingManager = require('./casting-manager');
 var Destinations = require('./destinations');
@@ -29,41 +31,6 @@
 var naming = require('./naming');
 var strings = require('./strings').currentLocale;
 
-function bindControlToDestination(control, destination) {
-  function updateOrdinal() {
-    handleDestinationOrdinalUpdate(control, destination);
-  }
-
-  if (destination) {
-    destination.onPlaceChange.add(control.setPlace);
-    destination.onSelect.add(control.select);
-    destination.onDeselect.add(control.deselect);
-    destination.onOrdinalChange.add(updateOrdinal);
-    control.setPlace(destination.getPlace());
-    /* Since these controls are 1:1 with destinations, we don't want to stay in
-     * a state where the control has invalid text but the destination is still
-     * valid; that would be confusing to the user (e.g. abandoned query string
-     * "restaurants" for destination 4 Privet Drive.) */
-    control.onPlaceChange.add(destination.setPlace);
-  }
-
-  updateOrdinal();
-
-  if (destination && destination.isSelected()) {
-    control.select();
-  } else {
-    control.deselect();
-  }
-
-  return destination? function unbind() {
-    destination.onPlaceChange.remove(control.setPlace);
-    destination.onSelect.remove(control.select);
-    destination.onDeselect.remove(control.deselect);
-    destination.onOrdinalChange.remove(updateOrdinal);
-    control.onPlaceChange.remove(destination.setPlace);
-  } : $.noop;
-}
-
 function buildStatusErrorStringMap(statusClass, stringGroup) {
   var dict = {};
   $.each(statusClass, function(name, value) {
@@ -73,7 +40,8 @@
 }
 
 function handleDestinationOrdinalUpdate(control, destination) {
-  control.setPlaceholder(describeDestination.descriptionOpenEnded(destination));
+  return control.setPlaceholder(
+    describeDestination.descriptionOpenEnded(destination));
 }
 
 var CMD_REGEX = /\/(\S*)(?:\s+(.*))?/;
@@ -134,70 +102,151 @@
       } else {
         this.error(strings['Trip is still initializing.']);
       }
+    },
+
+    castTimeline: function() {
+
     }
   },
 
   privates: {
-    handleDestinationAdd: function(destination) {
-      var map = this.map;
-
-      var control = this.timeline.add(destination.getIndex());
-      bindControlToDestination(control, destination);
-
-      control.setSearchBounds(map.getBounds());
-      map.onBoundsChange.add(control.setSearchBounds);
-
-      control.onFocus.add(function() {
-        if (!destination.isSelected()) {
-          map.closeActiveInfoWindow();
-          destination.select();
-        }
-      });
-
-      control.onSearch.add(function(results) {
-        /* There seems to be a bug where if you click a search suggestion (for
-         * a query, not a resolved location) in autocomplete, the input box
-         * under it gets clicked and focused... I haven't been able to figure
-         * out why. */
-        control.focus();
-
-        map.showSearchResults(results);
-      });
-
-      if (!destination.hasNext()) {
-        this.timeline.disableAdd();
-        var oldLast = this.timeline.get(-2);
-        if (oldLast) {
-          this.unbindLastDestinationSearchEvents(oldLast);
-        }
-        this.bindLastDestinationSearchEvents(control);
-      }
-
-      this.bindMiniFeedback(destination);
-
-      return {
-        destination: destination,
-        control: control
+    trap: function(asyncMethod) {
+      var self = this;
+      return function() {
+        return asyncMethod.apply(this, arguments).catch(self.error);
       };
     },
 
-    handleDestinationRemove: function(destination) {
-      var index = destination.getIndex();
-      this.unbindLastDestinationSearchEvents(this.timeline.remove(index));
+    bindControlToDestination: function(control, destination) {
+      var asyncs = [];
 
-      if (index >= this.destinations.count()) {
-        var lastControl = this.timeline.get(-1);
-        if (lastControl) {
-          this.bindLastDestinationSearchEvents(lastControl);
-          this.handleLastPlaceChange(lastControl.getPlace());
-        }
+      function updateOrdinalAsync() {
+        return handleDestinationOrdinalUpdate(control, destination);
       }
-      //TODO(rosswang): reselect?
+
+      var setPlace, select, deselect, updateOrdinal;
+
+      if (destination) {
+        setPlace = this.trap(control.setPlace);
+        select = this.trap(control.select);
+        deselect = this.trap(control.deselect);
+        updateOrdinal = this.trap(updateOrdinalAsync);
+
+        destination.onPlaceChange.add(setPlace);
+        destination.onSelect.add(select);
+        destination.onDeselect.add(deselect);
+        destination.onOrdinalChange.add(updateOrdinal);
+        asyncs.push(control.setPlace(destination.getPlace()));
+        /* Since these controls are 1:1 with destinations, we don't want to stay
+         * in a state where the control has invalid text but the destination is
+         * still valid; that would be confusing to the user (e.g. abandoned
+         * query string "restaurants" for destination 4 Privet Drive.) */
+        control.onPlaceChange.add(destination.setPlace);
+      }
+
+      asyncs.push(updateOrdinalAsync());
+
+      if (destination && destination.isSelected()) {
+        asyncs.push(control.select());
+      } else {
+        asyncs.push(control.deselect());
+      }
+
+      var unbind = destination? function() {
+        destination.onPlaceChange.remove(setPlace);
+        destination.onSelect.remove(select);
+        destination.onDeselect.remove(deselect);
+        destination.onOrdinalChange.remove(updateOrdinal);
+        control.onPlaceChange.remove(destination.setPlace);
+      } : $.noop;
+
+      return Promise.all(asyncs).then(function() {
+        return unbind;
+      }, function(err) {
+        unbind();
+        throw err;
+      });
+    },
+
+    handleDestinationAdd: function(destination) {
+      var self = this;
+
+      this.addDestinationToTimeline(this.timeline, destination)
+      .then(function() {
+        self.bindMiniFeedback(destination);
+      }).catch(this.error);
+    },
+
+    addDestinationToTimeline: function(timeline, destination) {
+      var self = this;
+      return timeline.add(destination.getIndex()).then(function(control) {
+        self.bindControlToDestination(control, destination);
+
+        var asyncs = [control.setSearchBounds(self.map.getBounds())];
+
+        control.onFocus.add(function() {
+          if (!destination.isSelected()) {
+            self.map.closeActiveInfoWindow();
+            destination.select();
+          }
+        });
+
+        control.onSearch.add(function(results) {
+          /* There seems to be a bug where if you click a search suggestion (for
+           * a query, not a resolved location) in autocomplete, the input box
+           * under it gets clicked and focused... I haven't been able to figure
+           * out why. */
+          self.trap(control.focus)();
+
+          self.map.showSearchResults(results);
+        });
+
+        if (!destination.hasNext()) {
+          asyncs.push(timeline.disableAdd());
+          var oldLastIndex = destination.getIndex() - 1;
+          if (oldLastIndex >= 0) {
+            asyncs.push(timeline.get(oldLastIndex)
+              .then(function(oldLast) {
+                if (oldLast) {
+                  self.unbindLastDestinationSearchEvents(oldLast);
+                }
+              }));
+          }
+          self.bindLastDestinationSearchEvents(control);
+        }
+
+        return Promise.all([asyncs]);
+      });
+    },
+
+    handleDestinationRemove: function(destination) {
+      var self = this;
+      var index = destination.getIndex();
+      this.timeline.remove(index).then(function(control) {
+        self.unbindLastDestinationSearchEvents(control);
+
+        if (index >= self.destinations.count()) {
+          return self.timeline.get(-1).then(function(lastControl) {
+            if (lastControl) {
+              self.bindLastDestinationSearchEvents(lastControl);
+              self.handleLastPlaceChange(lastControl.getPlace());
+            }
+          });
+        }
+        //TODO(rosswang): reselect?
+      }).catch(this.error);
     },
 
     handleTimelineDestinationAdd: function() {
-      this.destinations.add();
-      this.timeline.get(-1).focus();
+      var self = this;
+      var timeline = this.timeline;
+      function selectNewControl(control) {
+        control.focus().catch(self.error);
+        timeline.onDestinationAdd.remove(selectNewControl);
+      }
+      timeline.onDestinationAdd.add(selectNewControl);
+
+      this.destinations.add().select();
     },
 
     handleMiniDestinationAdd: function() {
@@ -305,16 +354,14 @@
 
     handleLastPlaceChange: function(place) {
       if (place) {
-        this.timeline.enableAdd();
+        this.timeline.enableAdd().catch(this.error);
       } else {
-        this.timeline.disableAdd();
+        this.timeline.disableAdd().catch(this.error);
       }
     },
 
     handleLastPlaceDeselected: function() {
-      /* Wait until next frame to allow selection/focus to update; we don't want
-       * to remove a box that has just received focus. */
-      raf(this.trimUnusedDestinations);
+      this.trimUnusedDestinations().catch(this.error);
     },
 
     runCommand: function(command, rest) {
@@ -360,6 +407,76 @@
       this.messages.push(message);
     },
 
+    handleSendCast: function(targetOwner, targetDeviceName, spec) {
+      switch (spec.panelName) {
+      case 'timeline':
+        this.sendTimelineCast(targetOwner, targetDeviceName);
+        break;
+      default:
+        this.error(strings.notCastable(spec.panelName));
+      }
+    },
+
+    handleReceiveCast: function(spec) {
+      switch (spec.panelName) {
+      case 'timeline':
+        this.receiveTimelineCast();
+        break;
+      default:
+        this.error(strings.notCastable(spec.panelName));
+      }
+    },
+
+    sendTimelineCast: function(targetOwner, targetDeviceName) {
+      var self = this;
+      this.vanadiumStartup.then(function(args) {
+        var endpoint = naming.rpcMount(
+          targetOwner, targetDeviceName, 'timeline');
+        return args.vanadiumWrapper.client(endpoint).then(function(ts) {
+          var tc = new TimelineClient(args.vanadiumWrapper.context(),
+            ts, self.dependencies);
+          tc.onError.add(self.error);
+          return self.adoptTimeline(tc);
+        });
+      }).catch(this.error);
+    },
+
+    receiveTimelineCast: function() {
+      var self = this;
+      var timeline = new Timeline(this.map.maps);
+      var ts = new TimelineService(timeline, this.dependencies);
+
+      this.vanadiumStartup.then(function(args) {
+        return args.vanadiumWrapper.server(
+          args.mountNames.rpcMount('timeline'), ts);
+      }).then(function() {
+        //TODO(rosswang): delay swap until after initialized
+        self.$appRoot.replaceWith(timeline.$);
+      }).catch(this.error);
+    },
+
+    adoptTimeline: function(timeline) {
+      var self = this;
+      timeline.onAddClick.add(this.handleTimelineDestinationAdd);
+      this.map.onBoundsChange.add(this.trap(timeline.setSearchBounds));
+      var async = Promise.resolve();
+      this.destinations.each(function(i, destination) {
+        async = async.then(function() {
+          return self.addDestinationToTimeline(timeline, destination);
+        });
+      });
+      this.timeline = timeline;
+      if (timeline.$) {
+        this.$timelineContainer.empty().append(timeline.$).show();
+        this.$toggleTimeline.show();
+      } else {
+        this.$timelineContainer.hide();
+        this.$toggleTimeline.hide();
+      }
+      this.map.invalidateSize();
+      return async;
+    },
+
     handleUserMessage: function(message, raw) {
       var match = CMD_REGEX.exec(raw);
       if (match) {
@@ -370,11 +487,27 @@
     },
 
     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);
+      var self = this;
+
+      var lastIndex = this.destinations.count() - 1;
+      if (lastIndex > 0) {
+        return this.timeline.get(lastIndex).then(function(lastControl) {
+          return Promise.all([
+            lastControl.getPlace(),
+            lastControl.isSelected()
+          ]);
+        }).then(function(conditions) {
+          if (!(conditions[0] || conditions[1])) {
+            //check for race condition; if we're no longer up-to-date
+            //just execute the next "iteration" without actually removing
+            if (lastIndex === self.destinations.count() - 1) {
+              self.destinations.remove(-1);
+            }
+            return self.trimUnusedDestinations();
+          }
+        });
+      } else {
+        return Promise.resolve();
       }
     },
 
@@ -418,7 +551,8 @@
     var timeline = this.timeline = new Timeline(maps);
 
     var error = this.error;
-    var vanadiumStartup = vanadiumWrapper.init(opts.vanadium)
+    var vanadiumStartup = this.vanadiumStartup =
+      vanadiumWrapper.init(opts.vanadium)
       .then(function(wrapper) {
         wrapper.onError.add(error);
         wrapper.onCrash.add(error);
@@ -440,10 +574,13 @@
       sbName = '/localhost:' + sbName;
     }
 
-    var sync = this.sync = new TravelSync(vanadiumStartup, {
+    this.dependencies = {
       maps: maps,
       placesService: map.createPlacesService()
-    }, sbName);
+    };
+
+    var sync = this.sync = new TravelSync(
+      vanadiumStartup, this.dependencies, sbName);
     sync.bindDestinations(destinations);
 
     this.info(strings['Connecting...'], sync.startup
@@ -477,17 +614,15 @@
 
     messages.onMessage.add(this.handleUserMessage);
 
-    timeline.onAddClick.add(this.handleTimelineDestinationAdd);
-
     var miniAddButton = this.miniAddButton = new AddButton();
     var miniDestinationSearch = this.miniDestinationSearch =
       new DestinationSearch(maps);
 
     miniAddButton.onClick.add(this.handleMiniDestinationAdd);
 
-    miniDestinationSearch.setPlaceholder(strings['Search']);
-    miniDestinationSearch.setSearchBounds(map.getBounds());
-    map.onBoundsChange.add(miniDestinationSearch.setSearchBounds);
+    miniDestinationSearch.setPlaceholder(strings['Search']).catch(error);
+    miniDestinationSearch.setSearchBounds(map.getBounds()).catch(error);
+    map.onBoundsChange.add(this.trap(miniDestinationSearch.setSearchBounds));
 
     miniDestinationSearch.onSearch.add(function(results) {
       if (results.length > 0) {
@@ -504,6 +639,8 @@
     miniDestinationSearch.onPlaceChange.add(function(place) {
       if (!place) {
         self.map.enableLocationSelection();
+      } else {
+        self.map.disableLocationSelection();
       }
     });
 
@@ -528,8 +665,7 @@
      * unnecessarily distort the text (which is an effect that is nice for the
      * add button, so that gets it explicitly). */
     var $timelineContainer = this.$timelineContainer = $('<div>')
-      .addClass('timeline-container collapsed')
-      .append(timeline.$);
+      .addClass('timeline-container collapsed');
 
     var $toggleTimeline = this.$toggleTimeline = $('<div>')
       .addClass('toggle-timeline no-select collapsed')
@@ -541,9 +677,10 @@
     map.addControls(maps.ControlPosition.LEFT_TOP, $miniPanel);
     map.addControls(maps.ControlPosition.LEFT_CENTER, $toggleTimeline);
 
-    var $domRoot = opts.domRoot? $(opts.domRoot) : $('body');
+    var $domRoot = this.$domRoot = opts.domRoot? $(opts.domRoot) : $('body');
+    var $appRoot = this.$appRoot = $('<div>');
 
-    $domRoot.append($timelineContainer, map.$);
+    $domRoot.append($appRoot.append($timelineContainer, map.$));
 
     this.initMiniFeedback();
 
@@ -553,18 +690,24 @@
         panelName: 'timeline'
       }
     });
-    castingManager.onAmbiguousCast.add(function(related, unknown) {
+    castingManager.onAmbiguousCast.add(function(related, unknown, other) {
       console.debug('ambiguous cast');
       console.debug(related);
       console.debug(unknown);
+      console.debug(other);
     });
     castingManager.onNoNearbyDevices.add(function() {
       self.error(strings.noNearbyDevices);
     });
     castingManager.onError.add(error);
 
+    castingManager.onSendCast.add(this.handleSendCast);
+    sync.onReceiveCast.add(this.handleReceiveCast);
+
+    this.adoptTimeline(timeline);
+
     destinations.add();
-    miniDestinationSearch.focus();
+    miniDestinationSearch.focus().catch(error);
 
     $domRoot.keypress(function() {
       messages.open();
diff --git a/src/travelsync.js b/src/travelsync.js
index dead53c..386dfec 100644
--- a/src/travelsync.js
+++ b/src/travelsync.js
@@ -8,6 +8,7 @@
 
 var defineClass = require('./util/define-class');
 
+var debug = require('./debug');
 var naming = require('./naming');
 
 var SyncgroupManager = require('./syncgroup-manager');
@@ -163,7 +164,8 @@
     },
 
     handleCast: function(ctx, serverCall, spec) {
-      console.debug('Cast target for ' + spec.panelName);
+      debug.log('Cast target for ' + spec.panelName);
+      this.onReceiveCast(spec);
     },
 
     clientPromise: function(endpoint) {
@@ -182,6 +184,11 @@
   constants: [ 'invitationManager', 'startup', 'status' ],
   events: {
     /**
+     * @param spec
+     */
+    onReceiveCast: '',
+
+    /**
      * @param newSize
      */
     onTruncateDestinations: '',
diff --git a/src/vanadium-wrapper/syncbase-wrapper.js b/src/vanadium-wrapper/syncbase-wrapper.js
index 4f1adc0..f704e99 100644
--- a/src/vanadium-wrapper/syncbase-wrapper.js
+++ b/src/vanadium-wrapper/syncbase-wrapper.js
@@ -132,7 +132,7 @@
         function(db, cb) {
           var t = db.table('t');
           var putToSyncbase = promisify(t.put.bind(t));
-          var deleteFromSyncbase = promisify(t.delete.bind(t));
+          var deleteFromSyncbase = promisify(t.deleteRange.bind(t));
 
           var ops = {
             put: function(k, v) {
@@ -217,7 +217,6 @@
       }
 
       if (this.writes.size) {
-        debug.log('Syncbase: deferring refresh due to writes in progress');
         return Promise.all(this.writes)
           .then(repull, repull);
 
@@ -256,7 +255,6 @@
               //no-op
             } else if (self.dirty) {
               abort = true;
-              debug.log('Syncbase: aborting refresh due to writes');
               resolve(repull()); //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.
@@ -402,7 +400,6 @@
 
     standardPut: function(fn, k, v) {
       k = joinKey(k);
-      debug.log('Syncbase: put ' + k + ' = ' + v);
       return fn(this.context, k, v);
     },
 
@@ -433,7 +430,7 @@
 
     this.runInBatch = promisify(syncbase.nosql.runInBatch);
     this.putToSyncbase = promisify(this.t.put.bind(this.t));
-    this.deleteFromSyncbase = promisify(this.t.delete.bind(this.t));
+    this.deleteFromSyncbase = promisify(this.t.deleteRange.bind(this.t));
 
     // Start the watch loop to periodically poll for changes from sync.
     // TODO(rosswang): Remove this once we have client watch.
diff --git a/test/travel.js b/test/travel.js
index 71da800..79e247e 100644
--- a/test/travel.js
+++ b/test/travel.js
@@ -19,7 +19,7 @@
 var PLACES = mockMaps.places.corpus;
 
 //All SLAs are expressed in milliseconds.
-var UI_SLA = 50;
+var UI_SLA = 100;
 /**
  * Syncbase doesn't yet provide us any notification that the first sync after
  * joining the initial sync groups has happened. This SLA is currently based on
diff --git a/tools/start_services.sh b/tools/start_services.sh
index 9175c45..1ecb678 100644
--- a/tools/start_services.sh
+++ b/tools/start_services.sh
@@ -9,25 +9,11 @@
 #
 # Optionally, the creds variable can specify a subdirectory.
 
+PATH=${PATH}:${V23_ROOT}/release/go/bin
+
 set -euo pipefail
-trap kill_child_processes INT TERM EXIT
-silence() {
-  "$@" &> /dev/null || true
-}
-# Copied from chat example app.
-kill_child_processes() {
-  # Attempt to stop child processes using the TERM signal.
-  if [[ -n "$(jobs -p -r)" ]]; then
-    silence pkill -P $$
-    sleep 1
-    # Kill any remaining child processes using the KILL signal.
-    if [[ -n "$(jobs -p -r)" ]]; then
-      silence sudo -u "${SUDO_USER}" pkill -9 -P $$
-    fi
-  fi
-}
+
 main() {
-  PATH=${PATH}:${V23_ROOT}/release/go/bin
   local -r TMP=tmp
   local -r CREDS=./tmp/creds/${creds-}
   local -r PORT=${port-4000}
@@ -51,7 +37,7 @@
   fi
 
   mkdir -p $TMP
-  ./bin/syncbased \
+  syncbased \
     --v=5 \
     --alsologtostderr=false \
     --root-dir=${TMP}/syncbase_${PORT} \
@@ -61,6 +47,5 @@
     --v23.tcp.address=${SYNCBASED_ADDR} \
     --v23.credentials=${CREDS} \
     --v23.permissions.literal="{\"Admin\":{\"In\":[\"${BLESSINGS}\"]},\"Write\":{\"In\":[\"${BLESSINGS}\"]},\"Read\":{\"In\":[\"${BLESSINGS}\"]},\"Resolve\":{\"In\":[\"${BLESSINGS}\"]},\"Debug\":{\"In\":[\"...\"]}}"
-  tail -f /dev/null  # wait forever
 }
 main "$@"