         # that this is false
         @objspy.should.have.properties __call_lock: true
 
+
+describe "timestamp_cmp", ->
+    tcmp = misc.timestamp_cmp
+    a = timestamp: new Date("2015-01-01")
+    b = timestamp: new Date("2015-01-02")
+
+    it "correctly compares timestamps", ->
+        tcmp(a, b).should.eql 1
+        tcmp(b, a).should.eql -1
+        tcmp(a, a).should.eql 0
+
+    it "handles missing timestamps gracefully", ->
+        tcmp(a, {}).should.eql -1
+        tcmp({}, b).should.eql 1
+
+describe "ActivityLog", =>
+    beforeEach =>
+        # e1 and e2 are deliberately on the same file
+        @e1 =
+            id: "1234"
+            timestamp: new Date("2015-01-01T12:34:55")
+            project_id: "c26db83a-7fa2-44a4-832b-579c18fac65f"
+            path: "foo/bar.baz"
+
+        @e2 =
+            id: "2345"
+            timestamp: new Date("2015-01-02T12:34:56")
+            project_id: "c26db83a-7fa2-44a4-832b-579c18fac65f"
+            path: "foo/bar.baz"
+
+        @e3 =
+            id: "3456"
+            timestamp: new Date("2015-01-01T12:34:55")
+            project_id: "c26db83a-7fa2-44a4-832b-579c18fac65f"
+            path: "x/y.z"
+            action: 'c26db83a-7fa2-44a4-832b-579c18fac65f/foo/bar.baz'
+            seen_by: "123456789"
+            read_by: "123456789"
+
+        @al = misc.activity_log
+                    events: [@e1, @e2, @e3]
+                    account_id: "123456789"
+                    notifications: {}
+
+    describe "constructor", =>
+        it "works correctly", =>
+            @al.should.have.properties
+                notifications:
+                    'c26db83a-7fa2-44a4-832b-579c18fac65f/foo/bar.baz':
+                        id: '2345'
+                        timestamp: new Date("2015-01-02T12:34:56")
+                    'c26db83a-7fa2-44a4-832b-579c18fac65f/x/y.z':
+                        id: '3456'
+                        timestamp: new Date("2015-01-01T12:34:55")
+                        "c26db83a-7fa2-44a4-832b-579c18fac65f/foo/bar.baz":
+                            "undefined": new Date("2015-01-01T12:34:55")
+                        read: new Date("2015-01-01T12:34:55")
+                        seen: new Date("2015-01-01T12:34:55")
+                account_id: "123456789"
+
+    describe "obj", =>
+        it "returns a map with the last notification", =>
+            @al.obj().should.eql
+                notifications:
+                    "c26db83a-7fa2-44a4-832b-579c18fac65f/foo/bar.baz":
+                        id: "2345"
+                        timestamp: new Date("2015-01-02T12:34:56")
+                    "c26db83a-7fa2-44a4-832b-579c18fac65f/x/y.z":
+                        id: "3456"
+                        timestamp: new Date("2015-01-01T12:34:55")
+                        "c26db83a-7fa2-44a4-832b-579c18fac65f/foo/bar.baz":
+                            "undefined": new Date("2015-01-01T12:34:55")
+                        read: new Date("2015-01-01T12:34:55")
+                        seen: new Date("2015-01-01T12:34:55")
+                account_id: "123456789"
+
+    describe "process", =>
+        it "correctly processes additional events", =>
+            @al.process([
+                id: "4567"
+                timestamp: new Date("2015-01-03T12:34:56")
+                project_id: "c26db83a-7fa2-44a4-832b-579c18fac65h"
+                path: "x/y.z"
+            ])
+            @al.notifications.should.eql
+                    "c26db83a-7fa2-44a4-832b-579c18fac65f/foo/bar.baz":
+                        id: "2345"
+                        timestamp: new Date("2015-01-02T12:34:56")
+                    "c26db83a-7fa2-44a4-832b-579c18fac65f/x/y.z":
+                        id: "3456"
+                        timestamp: new Date("2015-01-01T12:34:55")
+                        "c26db83a-7fa2-44a4-832b-579c18fac65f/foo/bar.baz":
+                            "undefined": new Date("2015-01-01T12:34:55")
+                        read: new Date("2015-01-01T12:34:55")
+                        seen: new Date("2015-01-01T12:34:55")
+                    "c26db83a-7fa2-44a4-832b-579c18fac65h/x/y.z":
+                        id: "4567"
+                        timestamp: new Date("2015-01-03T12:34:56")
+
 describe "encode_path", ->
     e = misc.encode_path
     it "escapes # and ?", ->

    it "correctly returns the keys of an object", ->
        k({a:5, xyz:'10'}).should.be.eql ['a', 'xyz']
        k({xyz:'10', a:5}).should.be.eql ['xyz', 'a']
    it "and doesn't choke on empty objects", ->
        k([]).should.be.eql []
        k({}).should.be.eql []


        @ssc(@string, @substr1, true).should.be.exactly 2
        @ssc(@string, @substr2, true).should.be.exactly 3
        @ssc(@string, @substr3, true).should.be.exactly 3
    it "counts empty strings", =>
        @ssc(@string, "").should.be.exactly 47


