Merge branch 'master' into agenda_on_vue
diff --git a/lib/whimsy/asf/agenda.rb b/lib/whimsy/asf/agenda.rb
index d13a56c..1a3f063 100644
--- a/lib/whimsy/asf/agenda.rb
+++ b/lib/whimsy/asf/agenda.rb
@@ -95,6 +95,9 @@
       @sections[section][:index] = index if @sections[section]
     end
 
+    # quick exit if none found -- non-standard format agenda
+    return [] if @sections.empty?
+
     # look for flags
     flagged_reports = Hash[@file[/ \d\. Committee Reports.*?\n\s+A\./m].
       scan(/# (.*?) \[(.*)\]/)] rescue {}
diff --git a/www/board/agenda/Gemfile b/www/board/agenda/Gemfile
index 26031e3..f5a468d 100644
--- a/www/board/agenda/Gemfile
+++ b/www/board/agenda/Gemfile
@@ -13,7 +13,7 @@
 
 gem 'rake'
 gem 'wunderbar'
-gem 'ruby2js', '>= 2.0.17'
+gem 'ruby2js'
 gem 'sinatra', '~> 2.0'
 gem 'nokogumbo'
 gem 'execjs', ('<2.5.1' if RUBY_VERSION =~ /^1/)
diff --git a/www/board/agenda/README.md b/www/board/agenda/README.md
index 344bbf3..3325165 100644
--- a/www/board/agenda/README.md
+++ b/www/board/agenda/README.md
@@ -118,17 +118,15 @@
        stylesheet from [bootstrap](http://getbootstrap.com/).
 
      * a `<div>` element with an id of `main` followed by the HTML used
-       to present the first page fetched from the server.  If you want to
-       see a different page, go to that page and hit refresh then view
-       source again.  This content is nicely indented and other than
-       an abundance of `data-reactid` attributes that React uses to keep
-       track of things, it is fairly straightforward.
+       to present the first page fetched from the server.  If you want to see
+       a different page, go to that page and hit refresh then view source
+       again.  This content is nicely indented and is fairly straightforward.
 
-    * a few `<script>` elements that pull in react, jquery, bootstrap, and
+    * a few `<script>` elements that pull in vue, jquery, bootstrap, and
       the agenda app itself.  I suggest that you leave that for the moment,
       we'll come back to it.
 
-    * an inline script that calls `React.render` with a datastructure
+    * an inline script that calls `new Vue` with a datastructure
       containing all the data the app needs on the client to do navigation.
       Most importantly, this page contains a parsed agenda.   Mentally file
       that away for later consideration.
@@ -172,23 +170,19 @@
 
  * the [views/pages/search.js.rb](views/pages/search.js.rb) file contains the
    code for the search page.  There are more methods defined here.  You will
-   find definitions for these methods in the React 
-   [Lifecycle Methods](http://facebook.github.io/react/docs/component-specs.html#lifecycle-methods).
-   You will see logic mixed with presentation.  React is deadly serious when
-   it adopted the slogan "rethink best practices".  What makes this work
-   is the component lifecycle that React provides.  Components have mutable
+   find definitions for these methods in the Vue 
+   [Lifecycle Methods](https://vuejs.org/v2/guide/instance.html#Lifecycle-Diagram).
+   You will see logic mixed with presentation.  What makes this work
+   is the component lifecycle that Vue provides.  Components have mutable
    state (which are the variables which are preceded by an `@` sign), and are
    passed immutable properties (variables preceded by two `@` signs).  Some
    methods are prohibited from mutating state (most notably: the `render`
-   method).  And one method (`componentWillReceiveProps`) even has access
-   to the before and after values for properties.  Don't get hung up on the
-   logic here, but do go to the navigation bar on the top right of the
-   browser page, and select `Search` and play with search live.
+   method).  Don't get hung up on the logic here, but do go to the navigation
+   bar on the top right of the browser page, and select `Search` and play with
+   search live.
 
-   Two items of special note.  `dangerouslySetInnerHTML` is React's
-   "don't blame me if things go wrong" way of allowing you to add text
-   that you have properly escaped into the content of an element.  Also,
-   we are directly making use of the browser APIs for updating the
+   An item of special note: we are directly making use of the browser APIs for
+   updating the
    [history](https://developer.mozilla.org/en-US/docs/Web/Guide/API/DOM/Manipulating_the_browser_history)
    of the window.
  
@@ -232,7 +226,7 @@
  * I mentioned previously that element names that start with a capital
    letter are effectively macros.  You've seen `Index`, `Search`, and
    `AddComment` classes, each of which start with a capital letter.  These
-   actually are examples of what React calls components that I have described
+   actually are examples of what Vue calls components that I have described
    as acting like macros.  `views/main.html.rb' contains the 'top'.
    [views/app.js.rb](views/app.js.rb) lists all of the files that make up the
    client side of the application.
@@ -243,7 +237,7 @@
    from the js.rb files mentioned above.  Undoubtedly you have seen small
    amounts of JavaScript before but I suspect that much of this looks foreign.
    Nicely indented, commented, vaguely familiar, but still somewhat foreign.
-   Many people these days generate JavaScript.  Popular with React is something
+   Many people these days generate JavaScript.  Popular with Vue is something
    called [JSX](http://facebook.github.io/react/docs/jsx-in-depth.html), but
    that's both controversial and [doesn't support if
    statements](http://facebook.github.io/react/tips/if-else-in-JSX.html).
@@ -298,12 +292,12 @@
     (expressed in Ruby, but compiled to JavaScript) can be tested.  It does so
     by setting up a http server (the code for which is in
     [spec/react_server.rb](spec/react_server.rb)) which runs arbitrary scripts
-    and returns the results as HTML.  This approach excels at testing a React
+    and returns the results as HTML.  This approach excels at testing a Vue
     component.
 
   * [spec/client_spec.rb](spec/client_spec.rb) takes this a bit further to
     do a client side unit test.  Instance variables set in tests are passed
-    to the React server, and arbitrary JavaScript code can be executed using
+    to the Vue server, and arbitrary JavaScript code can be executed using
     this data.  Output is in the form of XHTML-style tags which is then
     matched against CSS (or xpath) expressions.
 
@@ -390,7 +384,7 @@
     [views/layout/header.js.rb](views/layout/header.js.rb)
   * Adding the path to the `route` method in
     [views/router.js.rb](views/router.js.rb)
-  * Adding a React component for the page to `views/pages`
+  * Adding a Vue component for the page to `views/pages`
   * Adding any new files to [views/app.js.rb](views/app.js.rb)
   * Adding a specification to
     [specs/other_views_specs.rb](specs/other_views_specs.rb)
@@ -399,7 +393,7 @@
 
   * Adding a entry to the buttons list in
     [views/models/agenda.rb](views/models/agenda.rb)
-  * Adding a React component for the form to `views/forms`
+  * Adding a Vue component for the form to `views/forms`
   * Adding a server side action to `views/actions`.  A number of [actions
     from the current agenda
     tool](https://svn.apache.org/repos/infra/infrastructure/trunk/projects/whimsy/www/board/agenda/json)
@@ -423,7 +417,7 @@
    [Ruby2JS filters](https://github.com/rubys/ruby2js#filters) reduce this
    gap by converting many common Ruby methods calls to JavaScript equivalents
    (e.g., `a.include? b` becomes `a.indexOf(b) != -1`).  Currently the
-   agenda tool makes use of the `react`, `functions` and `require` filters.
+   agenda tool makes use of the `vue`, `functions` and `require` filters.
 
  * In Ruby there isn't a difference between accessing attributes and methods
    which have no arguments.  In JavaScript there is.  To make this work,
@@ -442,7 +436,7 @@
 
  * In Ruby, `$` is not a legal method name, so this common
    alias for `jQuery` isn't directly available.  jQuery isn't needed for
-   react, but is needed for Bootstrap.  As such there will be few places where
+   vue, but is needed for Bootstrap.  As such there will be few places where
    this will be needed.  As previously mentioned, I've considered using
    the `~` operator for this.
 
@@ -455,7 +449,7 @@
    framework for developing responsive, mobile first projects on the web
  * [capybara](https://github.com/jnicklas/capybara#readme) - helps you test
    web applications by simulating how a real user would interact with your app
- * [react](http://facebook.github.io/react/) - a JavaScript library for
+ * [vue](https://vuejs.org/) - a JavaScript library for
    building user interfaces 
  * [ruby2js](https://github.com/rubys/ruby2jw/#readme) - minimal yet
    extensible Ruby to JavaScript conversion. 
diff --git a/www/board/agenda/main.rb b/www/board/agenda/main.rb
index 3c41022..f3705a7 100755
--- a/www/board/agenda/main.rb
+++ b/www/board/agenda/main.rb
@@ -8,7 +8,7 @@
 require 'whimsy/asf/board'
 
 require 'wunderbar/sinatra'
-require 'wunderbar/react'
+require 'wunderbar/vue'
 require 'wunderbar/bootstrap/theme'
 require 'ruby2js/filter/functions'
 require 'ruby2js/filter/require'
@@ -38,6 +38,7 @@
 FileUtils.mkdir_p AGENDA_WORK if not Dir.exist? AGENDA_WORK
 
 require_relative './routes'
+require_relative './react'
 require_relative './models/pending'
 require_relative './models/agenda'
 require_relative './models/minutes'
diff --git a/www/board/agenda/package.json b/www/board/agenda/package.json
index 9d958e1..f29dbac 100644
--- a/www/board/agenda/package.json
+++ b/www/board/agenda/package.json
@@ -7,8 +7,9 @@
   },
   "devDependencies": {
     "jsdom": "^11.1.0",
+    "jsdom-global": "^3.0.2",
     "jquery": "^3.2.1",
-    "react": "^15.6.1",
-    "react-dom": "^15.6.1"
+    "vue": "^2.4.4",
+    "vue-server-renderer": "^2.4.4"
   }
 }
diff --git a/www/board/agenda/public/react/app.js b/www/board/agenda/public/react/app.js
new file mode 100644
index 0000000..781bd74
--- /dev/null
+++ b/www/board/agenda/public/react/app.js
@@ -0,0 +1,9081 @@
+//
+// Routing request based on path and query information in the URL
+//
+// Additionally provides defaults for color and title, and 
+// determines what buttons are required.
+//
+// Returns item, buttons, and options
+function Router() {};
+
+// route request based on path and query from the window location (URL)
+Router.route = function(path, query) {
+  var options = {};
+  var buttons = [];
+  var item, shepherd;
+
+  if (!path || path == ".") {
+    item = Agenda
+  } else if (path == "search") {
+    item = {view: Search, query: query}
+  } else if (path == "comments") {
+    item = {view: Comments}
+  } else if (path == "backchannel") {
+    item = {
+      view: Backchannel,
+      title: "Agenda Backchannel",
+      online: Server.online
+    }
+  } else if (path == "queue") {
+    item = {view: Queue, title: "Queued approvals and comments"};
+    if (Server.role != "director") item.title = "Queued comments"
+  } else if (path == "flagged") {
+    item = {view: Flagged, title: "Flagged reports"}
+  } else if (path == "missing") {
+    item = {
+      view: Missing,
+      title: "Missing reports",
+      buttons: [{form: InitialReminder}, {button: FinalReminder}]
+    }
+  } else if (new RegExp("^flagged/[-\\w]+$").test(path)) {
+    item = Agenda.find(path.slice(8, path.length));
+    options = {traversal: "flagged"}
+  } else if (new RegExp("^queue/[-\\w]+$").test(path)) {
+    item = Agenda.find(path.slice(6, path.length));
+    options = {traversal: "queue"}
+  } else if (new RegExp("^shepherd/queue/[-\\w]+$").test(path)) {
+    item = Agenda.find(path.slice(15, path.length));
+    options = {traversal: "shepherd"}
+  } else if (new RegExp("^shepherd/\\w+$").test(path)) {
+    shepherd = path.slice(9, path.length);
+
+    item = {
+      view: Shepherd,
+      shepherd: shepherd,
+      next: null,
+      prev: null,
+      title: "Shepherded by " + shepherd
+    };
+
+    // determine next/previous links
+    Agenda.index.forEach(function(i) {
+      var href;
+
+      if (i.shepherd && i.comments) {
+        if (i.shepherd.indexOf(" ") != -1) return;
+        href = "shepherd/" + i.shepherd;
+
+        if (i.shepherd > shepherd) {
+          if (!item.next || item.next.href > href) {
+            item.next = {title: i.shepherd, href: href}
+          }
+        } else if (i.shepherd < shepherd) {
+          if (!item.prev || item.prev.href < href) {
+            item.prev = {title: i.shepherd, href: href}
+          }
+        }
+      }
+    })
+  } else if (path == "help") {
+    item = {view: Help}
+  } else if (path == "bootstrap.html") {
+    item = {view: BootStrapPage, title: " "}
+  } else if (path == "cache/") {
+    item = {view: CacheStatus}
+  } else if (new RegExp("^cache/").test(path)) {
+    item = {view: CachePage}
+  } else if (path == "fy22") {
+    item = {
+      view: FY22,
+      title: "FY22 Budget Worksheet",
+      color: "available",
+      prev: {title: "Discussion Items", href: "Discussion-Items"},
+      next: {title: "Action Items", href: "Action-Items"}
+    }
+  } else {
+    item = Agenda.find(path);
+
+    if (path == "Discussion-Items" && /^2017-02/.test(Agenda.date)) {
+      item.next = {title: "FY22 Budget Worksheet", href: "fy22"}
+    }
+  };
+
+  // bail unless an item was found
+  if (!item) return {};
+
+  // provide defaults for required properties
+  item.color = item.color || "blank";
+  item.title = item.title || item.view.displayName;
+
+  // determine what buttons are required, merging defaults, form provided
+  // overrides, and any overrides provided by the agenda item itself
+  buttons = item.buttons;
+  if (item.view.buttons) buttons = item.view.buttons().concat(buttons || []);
+
+  if (buttons) {
+    buttons = buttons.map(function(button) {
+      var props = {
+        text: "button",
+        attrs: {className: "btn"},
+        form: button.form
+      };
+
+      // form overrides
+      var form = button.form;
+
+      if (form && form.button) {
+        for (var name in form.button) {
+          if (name == "text") {
+            props.text = form.button.text
+          } else if (name == "class" || name == "classname") {
+            props.attrs.className += " " + form.button[name].replace(/_/g, "-")
+          } else {
+            props.attrs[name.replace(/_/g, "-")] = form.button[name]
+          }
+        }
+      } else {
+        // no form or form has no separate button: so this is just a button
+        delete props.text;
+        props.type = button.button || form;
+        props.attrs = {item: item, server: Server}
+      };
+
+      // item overrides
+      for (var name in button) {
+        if (name == "text") {
+          props.text = button.text
+        } else if (name == "class" || name == "classname") {
+          props.attrs.className += " " + button[name].replace(/_/g, "-")
+        } else if (name != "form") {
+          props.attrs[name.replace(/_/g, "-")] = button[name]
+        }
+      };
+
+      // clear modals
+      if (typeof document !== 'undefined') {
+        document.body.classList.remove("modal-open")
+      };
+
+      return props
+    })
+  };
+
+  return {item: item, buttons: buttons, options: options}
+};
+
+//
+// Respond to keyboard events
+//
+function Keyboard() {};
+
+Keyboard.initEventHandlers = function() {
+  // keyboard navigation (unless on the search screen)
+  document.body.onkeydown = function(event) {
+    if ($("#search-text")[0] || $(".modal-open")[0] || $(".modal.in")[0]) return;
+
+    if (!event.altKey && ["input", "textarea"].indexOf(document.activeElement.tagName.toLowerCase()) != -1) {
+      return
+    };
+
+    if (event.metaKey || event.ctrlKey) return;
+    var link, info;
+
+    if (event.keyCode == 37) {
+      link = $("a[rel=prev]")[0];
+
+      if (link) {
+        link.click();
+        return false
+      }
+    } else if (event.keyCode == 39) {
+      link = $("a[rel=next]")[0];
+
+      if (link) {
+        link.click();
+        return false
+      }
+    } else if (event.keyCode == 13) {
+      link = $(".default")[0];
+      if (link) Main.navigate(link.getAttribute("href"));
+      return false
+    } else if (event.keyCode == 67) {
+      link = $("#comments")[0];
+
+      if (link) {
+        jQuery("html, body").animate({scrollTop: link.offsetTop}, "slow")
+      } else {
+        Main.navigate("comments")
+      };
+
+      return false
+    } else if (event.keyCode == 73) {
+      info = document.getElementById("info");
+      if (info) info.click();
+      return false
+    } else if (event.keyCode == 77) {
+      Main.navigate("missing");
+      return false
+    } else if (event.keyCode == 78) {
+      $("#nav").click();
+      return false
+    } else if (event.keyCode == 65) {
+      Main.navigate(".");
+      return false
+    } else if (event.keyCode == 83) {
+      if (event.shiftKey) {
+        Server.role = "secretary";
+        Main.refresh()
+      } else {
+        link = $("#shepherd")[0];
+        if (link) Main.navigate(link.getAttribute("href"))
+      };
+
+      return false
+    } else if (event.keyCode == 88) {
+      if (Main.item.attach && Minutes.started && !Minutes.complete) {
+        Chat.changeTopic({
+          user: Server.userid,
+          link: Main.item.href,
+          text: "current topic: " + Main.item.title
+        });
+
+        return false
+      }
+    } else if (event.keyCode == 81) {
+      Main.navigate("queue");
+      return false
+    } else if (event.keyCode == 70) {
+      Main.navigate("flagged");
+      return false
+    } else if (event.keyCode == 66) {
+      Main.navigate("backchannel");
+      return false
+    } else if (event.shiftKey && event.keyCode == 191) {
+      Main.navigate("help");
+      return false
+    } else if (event.keyCode == 82) {
+      clock_counter++;
+      Main.refresh();
+
+      post("refresh", {agenda: Agenda.file}, function(response) {
+        clock_counter--;
+        Agenda.load(response.agenda, response.digest);
+        Main.refresh()
+      });
+
+      return false
+    } else if (event.keyCode == 61 || event.keyCode == 187) {
+      Main.navigate("cache/");
+      return false
+    }
+  }
+};
+
+// A convenient place to stash server data
+var Server = {};
+
+// controls display of clock in the header
+var clock_counter = 0;
+
+//
+// function to assist with production of HTML and regular expressions
+//
+// Escape HTML characters so that raw text can be safely inserted as HTML
+function htmlEscape(string) {
+  return string.replace(htmlEscape.chars, function(c) {
+    return htmlEscape.replacement[c]
+  })
+};
+
+htmlEscape.chars = /[&<>]/g;
+htmlEscape.replacement = {"&": "&amp;", "<": "&lt;", ">": "&gt;"};
+
+// escape a string so that it can be used as a regular expression
+function escapeRegExp(string) {
+  // https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions
+  return string.replace(
+    new RegExp("([.*+?^=!:${}()|\\[\\]/\\\\])", "g"),
+    "\\$1"
+  )
+};
+
+// Replace http[s] links in text with anchor tags
+function hotlink(string) {
+  return string.replace(hotlink.regexp, function(match, pre, link) {
+    return pre + "<a href='" + link + "'>" + link + "</a>"
+  })
+};
+
+hotlink.regexp = new RegExp("(^|[\\s.:;?\\-\\]<\\(])(https?://[-\\w;/?:@&=+$.!~*'()%,#]+[\\w/])(?=$|[\\s.:;,?\\-\\[\\]&\\)])", "g");
+
+//
+// Requests to the server
+//
+// "AJAX" style post request to the server, with a callback
+function post(target, data, block) {
+  var xhr = new XMLHttpRequest();
+  xhr.open("POST", "../json/" + target, true);
+
+  xhr.setRequestHeader(
+    "Content-Type",
+    "application/json;charset=utf-8"
+  );
+
+  xhr.responseType = "text";
+
+  xhr.onreadystatechange = function() {
+    var message;
+
+    if (xhr.readyState == 4) {
+      data = null;
+
+      try {
+        if (xhr.status == 200) {
+          data = JSON.parse(xhr.responseText);
+          if (data.exception) alert("Exception\n" + data.exception)
+        } else if (xhr.status == 404) {
+          alert("Not Found: json/" + target)
+        } else if (xhr.status >= 400) {
+          if (!xhr.response) {
+            message = "Exception - " + xhr.statusText
+          } else if (xhr.response.exception) {
+            message = "Exception\n" + xhr.response.exception
+          } else {
+            message = "Exception\n" + JSON.parse(xhr.responseText).exception
+          };
+
+          console.log(message);
+          alert(message)
+        }
+      } catch (e) {
+        console.log(e)
+      };
+
+      block(data);
+      Main.refresh()
+    }
+  };
+
+  xhr.send(JSON.stringify(data))
+};
+
+// "AJAX" style get request to the server, with a callback
+//
+// Would love to use/build on 'fetch', but alas:
+//
+//   https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API#Browser_compatibility 
+function retrieve(target, type, block) {
+  var xhr = new XMLHttpRequest();
+
+  xhr.onreadystatechange = function() {
+    var data, message;
+
+    if (xhr.readyState == 1) {
+      clock_counter++;
+      setTimeout(function() {Main.refresh()}, 0)
+    } else if (xhr.readyState == 4) {
+      data = null;
+
+      try {
+        if (xhr.status == 200) {
+          if (type == "json") {
+            data = xhr.response || JSON.parse(xhr.responseText)
+          } else {
+            data = xhr.responseText
+          }
+        } else if (xhr.status == 404) {
+          alert("Not Found: " + type + "/" + target)
+        } else if (xhr.status >= 400) {
+          if (!xhr.response) {
+            message = "Exception - " + xhr.statusText
+          } else if (xhr.response.exception) {
+            message = "Exception\n" + xhr.response.exception
+          } else {
+            message = "Exception\n" + JSON.parse(xhr.responseText).exception
+          };
+
+          console.log(message);
+          alert(message)
+        }
+      } catch (e) {
+        console.log(e)
+      };
+
+      block(data);
+      clock_counter--;
+      Main.refresh()
+    }
+  };
+
+  if (/^https?:/.test(target)) {
+    xhr.open("GET", target, true);
+    if (type == "json") xhr.setRequestHeader("Accept", "application/json")
+  } else {
+    xhr.open("GET", "../" + type + "/" + target, true)
+  };
+
+  xhr.responseType = type;
+  xhr.send()
+};
+
+//
+// Reflow comments and lines
+//
+function Flow() {};
+
+// reflow comment
+Flow.comment = function(comment, initials, indent) {
+  if (typeof indent === 'undefined') indent = "    ";
+  var lines = comment.split("\n");
+  var len = 71 - indent.length;
+
+  for (var i = 0; i < lines.length; i++) {
+    lines[i] = ((i == 0 ? initials + ": " : indent + " ")) + lines[i].replace(
+      new RegExp("(.{1," + len + "})( +|$\\n?)|(.{1," + len + "})", "g"),
+      "$1$3\n" + indent
+    ).trim()
+  };
+
+  return lines.join("\n")
+};
+
+// reflow text
+Flow.text = function(text, indent) {
+  if (typeof indent === 'undefined') indent = "";
+
+  // join consecutive lines (making exception for <markers> like <private>)
+  text = text.replace(/([^\s>])\n(\w)/g, "$1 $2");
+
+  // reflow each line
+  var lines = text.split("\n");
+  var len = 78 - indent.length;
+
+  for (var i = 0; i < lines.length; i++) {
+    indent = lines[i].match(/( *)(.?.?)(.*)/m);
+    var n;
+
+    if ((indent[1] == "" && indent[2] != "* ") || indent[3] == "") {
+      // not indented (or short) -> split
+      lines[i] = lines[i].replace(
+        new RegExp("(.{1," + len + "})( +|$\\n?)|(.{1," + len + "})", "g"),
+        "$1$3\n"
+      ).replace(/[\n\r]+$/, "")
+    } else {
+      // preserve indentation.  indent[2] is the 'bullet' (if any) and is
+      // only to be placed on the first line.
+      n = 76 - indent[1].length;
+
+      lines[i] = indent[3].replace(
+        new RegExp("(.{1," + n + "})( +|$\\n?)|(.{1," + n + "})", "g"),
+        indent[1] + "  $1$3\n"
+      ).replace(indent[1] + "  ", indent[1] + indent[2]).replace(
+        /[\n\r]+$/,
+        ""
+      )
+    }
+  };
+
+  return lines.join("\n")
+};
+
+//
+// Split comments string into individual comments
+//
+function splitComments(string) {
+  var results = [];
+  if (!string) return results;
+  var comment = "";
+
+  string.split("\n").forEach(function(line) {
+    if (/^\S/.test(line)) {
+      if (comment.length != 0) results.push(comment);
+      comment = line
+    } else {
+      comment += "\n" + line
+    }
+  });
+
+  if (comment.length != 0) results.push(comment);
+  return results
+};
+
+//
+// Main component, responsible for:
+//
+//  * Initial loading and polling of the agenda
+//
+//  * Rendering a Header, a item view, and a Footer
+//
+//  * Resizing view to leave room for the Header and Footer
+//
+var Main = React.createClass({
+  displayName: "Main",
+  statics: {refresh: function() {}},
+
+  getInitialState: function() {
+    return {}
+  },
+
+  // common layout for all pages: header, main, footer, and forms
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = ["span", null];
+      var view;
+
+      if (!self.state.item) {
+        $_.push(React.createElement("p", null, "Not found"))
+      } else {
+        $_.push(React.createElement(Header, {item: self.state.item}));
+        view = null;
+
+        $_.push(React.createElement("main", null, React.createElement(
+          self.state.item.view,
+
+          {item: self.state.item, ref: function(component) {
+            Main.view = component
+          }}
+        )));
+
+        $_.push(React.createElement(Footer, {
+          item: self.state.item,
+          buttons: self.state.buttons,
+          options: self.state.options
+        }));
+
+        // emit hidden forms associated with the buttons displayed on this page
+        if (self.state.buttons) {
+          self.state.buttons.forEach(function(button) {
+            if (button.form) {
+              $_.push(React.createElement(
+                button.form,
+                {item: self.state.item, server: Server, button: button}
+              ))
+            }
+          })
+        }
+      };
+
+      return $_
+    }())
+  },
+
+  // initial load of the agenda, and route first request
+  componentWillMount: function() {
+    // copy server info for later use
+    for (var prop in this.props.server) {
+      Server[prop] = this.props.server[prop]
+    };
+
+    Agenda.load(this.props.page.parsed, this.props.page.digest);
+    Minutes.load(this.props.page.minutes);
+    this.route(this.props.page.path, this.props.page.query);
+
+    // free memory
+    this.props.page.parsed = null
+  },
+
+  // encapsulate calls to the router
+  route: function(path, query) {
+    var route = Router.route(path, query);
+
+    this.setState({
+      item: route.item,
+      buttons: route.buttons,
+      options: route.options
+    });
+
+    if (!Main.item || Main.item.view != route.item.view) Main.view = null;
+    Main.item = route.item
+  },
+
+  // navigation method that updates history (back button) information
+  navigate: function(path, query) {
+    history.state.scrollY = window.scrollY;
+    history.replaceState(history.state, null, history.path);
+    Main.scrollTo = 0;
+    this.route(path, query);
+    history.pushState({path: path, query: query}, null, path);
+    window.onresize()
+  },
+
+  // refresh the current page
+  refresh: function() {
+    this.route(history.state.path, history.state.query)
+  },
+
+  // additional client side initialization
+  componentDidMount: function() {
+    var self = this;
+
+    // export navigate and refresh methods
+    Main.navigate = this.navigate;
+    Main.refresh = this.refresh;
+
+    // store initial state in history, taking care not to overwrite
+    // history set by the Search component.
+    var path, base;
+
+    if (!history.state || !history.state.query) {
+      path = this.props.page.path;
+
+      if (path == "bootstrap.html") {
+        path = document.location.href;
+        base = document.getElementsByTagName("base")[0].href;
+        if (path.substring(0, base.length) == base) path = path.slice(base.length)
+      };
+
+      history.replaceState({path: path}, null, path)
+    };
+
+    // listen for back button, and re-route/re-render when it occcurs
+    window.addEventListener("popstate", function(event) {
+      if (event.state && typeof event.state.path !== 'undefined') {
+        Main.scrollTo = event.state.scrollY || 0;
+        self.route(event.state.path, event.state.query)
+      }
+    });
+
+    // start watching keystrokes
+    Keyboard.initEventHandlers();
+
+    // whenever the window is resized, adjust margins of the main area to
+    // avoid overlapping the header and footer areas
+    window.onresize = function() {
+      var main = document.getElementsByTagName("main")[0];
+
+      if (window.innerHeight <= 400 && document.body.scrollHeight > window.innerHeight) {
+        document.querySelector("footer").style.position = "relative";
+        document.querySelector("header").style.position = "relative";
+        main.style.marginTop = 0;
+        main.style.marginBottom = 0
+      } else {
+        document.querySelector("footer").style.position = "fixed";
+        document.querySelector("header").style.position = "fixed";
+        main.style.marginTop = document.querySelector("header.navbar").clientHeight + "px";
+        main.style.marginBottom = document.querySelector("footer.navbar").clientHeight + "px"
+      };
+
+      if (Main.scrollTo == 0 || Main.scrollTo) {
+        if (Main.scrollTo == -1) {
+          jQuery("html, body").animate(
+            {scrollTop: document.documentElement.scrollHeight},
+            "fast"
+          )
+        } else {
+          window.scrollTo(0, Main.scrollTo);
+          Main.scrollTo = null
+        }
+      }
+    };
+
+    // do an initial resize
+    Main.scrollTo = 0;
+    window.onresize();
+
+    // if agenda is stale, fetch immediately; otherwise save etag
+    Agenda.fetch(this.props.page.etag, this.props.page.digest);
+
+    // start Service Worker
+    if (PageCache.enabled) PageCache.register();
+
+    // start backchannel
+    Events.monitor()
+  },
+
+  // after each subsequent re-rendering, resize main window
+  componentDidUpdate: function() {
+    window.onresize()
+  }
+});
+
+//
+// Header: title on the left, dropdowns on the right
+//
+// Also keeps the window/tab title in sync with the header title
+//
+// Finally: make info dropdown status 'sticky'
+var Header = React.createClass({
+  displayName: "Header",
+
+  getInitialState: function() {
+    return {infodropdown: null}
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = [
+        "header",
+        {className: "navbar navbar-fixed-top " + (self.props.item.color || "")}
+      ];
+
+      $_.push(React.createElement(
+        "div",
+        {className: "navbar-brand"},
+        self.props.item.title
+      ));
+
+      if (/^7/.test(self.props.item.attach) && /^Establish /.test(self.props.item.title)) {
+        $_.push(React.createElement(
+          PodlingNameSearch,
+          {item: self.props.item}
+        ))
+      };
+
+      if (clock_counter > 0) {
+        $_.push(React.createElement("span", {id: "clock"}, "⌛"))
+      };
+
+      $_.push(React.createElement.apply(React, function() {
+        var $_ = ["ul", {className: "nav nav-pills navbar-right"}];
+
+        // pending count
+        if (Pending.count > 0) {
+          $_.push(React.createElement(
+            "li",
+            {className: "label label-danger"},
+            React.createElement(Link, {text: Pending.count, href: "queue"})
+          ))
+        };
+
+        // 'info'/'online' dropdown
+        //
+        if (self.props.item.attach) {
+          $_.push(React.createElement(
+            "li",
+            {className: "report-info dropdown " + (self.state.infodropdown || "")},
+
+            React.createElement(
+              "a",
+              {className: "dropdown-toggle", id: "info", onClick: self.toggleInfo},
+              "info",
+              React.createElement("b", {className: "caret"})
+            ),
+
+            React.createElement(
+              Info,
+              {item: self.props.item, position: "dropdown-menu"}
+            )
+          ))
+        } else if (self.props.item.online) {
+          $_.push(React.createElement(
+            "li",
+            {className: "dropdown"},
+
+            React.createElement(
+              "a",
+              {className: "dropdown-toggle", id: "info", "data-toggle": "dropdown"},
+              "online",
+              React.createElement("b", {className: "caret"})
+            ),
+
+            React.createElement.apply(React, function() {
+              var $_ = ["ul", {className: "online dropdown-menu"}];
+
+              self.props.item.online.forEach(function(id) {
+                $_.push(React.createElement(
+                  "li",
+                  null,
+                  React.createElement("a", {href: "/roster/committer/" + id}, id)
+                ))
+              });
+
+              return $_
+            }())
+          ))
+        } else {
+          $_.push(React.createElement.apply(React, function() {
+            var $_ = ["li", {className: "dropdown"}];
+
+            $_.push(React.createElement(
+              "a",
+              {className: "dropdown-toggle", id: "info", "data-toggle": "dropdown"},
+              "summary",
+              React.createElement("b", {className: "caret"})
+            ));
+
+            var summary = self.props.item.summary || Agenda.summary;
+
+            $_.push(React.createElement.apply(React, function() {
+              var $_ = [
+                "table",
+                {className: "table-bordered online dropdown-menu"}
+              ];
+
+              summary.forEach(function(status) {
+                var text = status.text;
+                if (status.count == 1) text = text.replace(/s$/, "");
+
+                $_.push(React.createElement(
+                  "tr",
+                  {className: status.color},
+
+                  React.createElement(
+                    "td",
+                    null,
+                    React.createElement(Link, {text: status.count, href: status.href})
+                  ),
+
+                  React.createElement(
+                    "td",
+                    null,
+                    React.createElement(Link, {text: text, href: status.href})
+                  )
+                ))
+              });
+
+              return $_
+            }()));
+
+            return $_
+          }()))
+        };
+
+        // 'navigation' dropdown
+        //
+        $_.push(React.createElement(
+          "li",
+          {className: "dropdown"},
+
+          React.createElement(
+            "a",
+            {className: "dropdown-toggle", id: "nav", "data-toggle": "dropdown"},
+            "navigation",
+            React.createElement("b", {className: "caret"})
+          ),
+
+          React.createElement.apply(React, function() {
+            var $_ = ["ul", {className: "dropdown-menu"}];
+
+            $_.push(React.createElement(
+              "li",
+              null,
+              React.createElement(Link, {id: "agenda", text: "Agenda", href: "."})
+            ));
+
+            Agenda.index.forEach(function(item) {
+              if (item.index) {
+                $_.push(React.createElement(
+                  "li",
+                  null,
+                  React.createElement(Link, {text: item.index, href: item.href})
+                ))
+              }
+            });
+
+            $_.push(React.createElement("li", {className: "divider"}));
+
+            $_.push(React.createElement(
+              "li",
+              null,
+              React.createElement(Link, {text: "Search", href: "search"})
+            ));
+
+            $_.push(React.createElement(
+              "li",
+              null,
+              React.createElement(Link, {text: "Comments", href: "comments"})
+            ));
+
+            var shepherd = Agenda.shepherd;
+
+            if (shepherd) {
+              $_.push(React.createElement("li", null, React.createElement(
+                Link,
+                {id: "shepherd", text: "Shepherd", href: "shepherd/" + shepherd}
+              )))
+            };
+
+            $_.push(React.createElement("li", null, React.createElement(
+              Link,
+              {id: "queue", text: "Queue", href: "queue"}
+            )));
+
+            $_.push(React.createElement("li", {className: "divider"}));
+
+            $_.push(React.createElement("li", null, React.createElement(
+              Link,
+              {id: "backchannel", text: "Backchannel", href: "backchannel"}
+            )));
+
+            $_.push(React.createElement(
+              "li",
+              null,
+              React.createElement(Link, {id: "help", text: "Help", href: "help"})
+            ));
+
+            return $_
+          }())
+        ));
+
+        return $_
+      }()));
+
+      return $_
+    }())
+  },
+
+  // set history on initial rendering
+  componentDidMount: function() {
+    this.componentDidUpdate()
+  },
+
+  // update title to match the item title whenever page changes
+  componentDidUpdate: function() {
+    var title = document.getElementsByTagName("title")[0];
+
+    if (title.textContent != this.props.item.title) {
+      title.textContent = this.props.item.title
+    }
+  },
+
+  // toggle info dropdown
+  toggleInfo: function() {
+    return this.setState({infodropdown: (this.state.infodropdown ? null : "open")})
+  }
+});
+
+//
+// Layout footer consisting of a previous link, any number of buttons,
+// followed by a next link.
+//
+// Overrides previous and next links when traversal is queue, shepherd, or
+// Flagged.  Injects the flagged items into the flow once the meeting starts
+// (last additional officer <-> first flagged &&
+//  last flagged <-> first Special order)
+//
+var Footer = React.createClass({
+  displayName: "Footer",
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = [
+        "footer",
+        {className: "navbar navbar-fixed-bottom " + (self.props.item.color || "")}
+      ];
+
+      //
+      // Previous link
+      //
+      var link = self.props.item.prev;
+      var prefix = "";
+
+      if (self.props.options.traversal == "queue") {
+        prefix = "queue/";
+
+        while (link && !link.ready_for_review(Server.initials)) {
+          link = link.prev
+        };
+
+        link = link || {href: "../queue", title: "Queue"}
+      } else if (self.props.options.traversal == "shepherd") {
+        prefix = "shepherd/queue/";
+
+        while (link && link.shepherd != self.props.item.shepherd) {
+          link = link.prev
+        };
+
+        link = link || {
+          href: "../" + self.props.item.shepherd,
+          title: "Shepherd"
+        }
+      } else if (self.props.options.traversal == "flagged") {
+        prefix = "flagged/";
+
+        while (link && !link.flagged) {
+          link = link.prev
+        };
+
+        if (!link) {
+          if (Minutes.started) {
+            link = Agenda.index.find(function(item) {
+              return item.attach == "A"
+            }).prev;
+
+            prefix = ""
+          };
+
+          link = link || {href: "../flagged", title: "Flagged"}
+        }
+      } else if (Minutes.started && /\d/.test(self.props.item.attach) && link && /^[A-Z]/.test(link.attach)) {
+        Agenda.index.forEach(function(item) {
+          if (item.flagged) {
+            prefix = "flagged/";
+            link = item
+          }
+        })
+      };
+
+      if (link) {
+        $_.push(React.createElement(Link, {
+          className: "backlink navbar-brand " + (link.color || ""),
+          text: link.title,
+          rel: "prev",
+          href: prefix + link.href
+        }))
+      } else if (self.props.item.prev || self.props.item.next) {
+        // without this, Chrome will sometimes make the footer too tall
+        $_.push(React.createElement("a", {className: "navbar-brand"}))
+      };
+
+      //
+      // Buttons
+      //
+      $_.push(React.createElement.apply(React, function() {
+        var $_ = ["span", null];
+
+        if (self.props.buttons) {
+          self.props.buttons.forEach(function(button) {
+            if (button.text) {
+              $_.push(React.createElement("button", button.attrs, button.text))
+            } else if (button.type) {
+              $_.push(React.createElement(button.type, button.attrs))
+            }
+          })
+        };
+
+        return $_
+      }()));
+
+      //
+      // Next link
+      //
+      link = self.props.item.next;
+
+      if (self.props.options.traversal == "queue") {
+        while (link && !link.ready_for_review(Server.initials)) {
+          link = link.next
+        };
+
+        link = link || {href: "queue", title: "Queue"}
+      } else if (self.props.options.traversal == "shepherd") {
+        while (link && link.shepherd != self.props.item.shepherd) {
+          link = link.next
+        };
+
+        link = link || {
+          href: "shepherd/" + self.props.item.shepherd,
+          title: "shepherd"
+        }
+      } else if (self.props.options.traversal == "flagged") {
+        prefix = "flagged/";
+
+        while (link && !link.flagged) {
+          if (Minutes.started && link.index) {
+            prefix = "";
+            break
+          } else {
+            link = link.next
+          }
+        };
+
+        link = link || {href: "flagged", title: "Flagged"}
+      } else if (Minutes.started && link && link.attach == "A") {
+        while (link && !link.flagged && /^[A-Z]/.test(link.attach)) {
+          link = link.next
+        };
+
+        if (link && /^[A-Z]/.test(link.attach)) prefix = "flagged/"
+      };
+
+      if (link) {
+        if (!/^[A-Z]/.test(link.attach)) prefix = "";
+
+        $_.push(React.createElement(Link, {
+          className: "nextlink navbar-brand " + (link.color || ""),
+          text: link.title,
+          rel: "next",
+          href: prefix + link.href
+        }))
+      } else if (self.props.item.prev || self.props.item.next) {
+        // without this, Chrome will sometimes make the footer too tall
+        $_.push(React.createElement(
+          "a",
+          {className: "nextarea navbar-brand"}
+        ))
+      };
+
+      return $_
+    }())
+  }
+});
+
+//
+// Secretary version of Adjournment section: shows todos
+//
+var Adjournment = React.createClass({
+  displayName: "Adjournment",
+
+  getInitialState: function() {
+    this.state = {};
+
+    Todos.set({
+      add: [],
+      remove: [],
+      establish: [],
+      feedback: [],
+      minutes: {},
+      loading: true,
+      fetched: false
+    });
+
+    return this.state
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement(
+      "section",
+      {className: "flexbox"},
+
+      React.createElement.apply(React, function() {
+        var $_ = ["section", null];
+
+        $_.push(React.createElement(
+          "pre",
+          {className: "report"},
+          self.props.item.text
+        ));
+
+        if (!Todos.loading || Todos.fetched) {
+          $_.push(React.createElement("h3", null, "Post Meeting actions"));
+
+          if (Todos.add.length == 0 && Todos.remove.length == 0 && Todos.establish.length == 0) {
+            if (Todos.loading) {
+              $_.push(React.createElement("em", null, "Loading..."))
+            } else {
+              $_.push(React.createElement("p", {className: "comment"}, "complete"))
+            }
+          }
+        };
+
+        if (Todos.add.length != 0) {
+          $_.push(React.createElement(TodoActions, {action: "add"}))
+        };
+
+        if (Todos.remove.length != 0) {
+          $_.push(React.createElement(TodoActions, {action: "remove"}))
+        };
+
+        if (Todos.establish.length != 0) {
+          $_.push(React.createElement(EstablishActions, {action: "remove"}))
+        };
+
+        if (Todos.feedback.length != 0) $_.push(React.createElement(FeedbackReminder));
+
+        // display a list of completed actions
+        var completed = Todos.minutes.todos;
+
+        if (completed && Object.keys(completed).length > 0 && ((completed.added && completed.added.length != 0) || (completed.removed && completed.removed.length != 0) || (completed.established && completed.established.length != 0) || (completed.feedback_sent && completed.feedback_sent.length != 0))) {
+          $_.push(React.createElement("h3", null, "Completed actions"));
+
+          if (completed.added && completed.added.length != 0) {
+            $_.push(React.createElement("p", null, "Added to PMC chairs"));
+
+            $_.push(React.createElement.apply(React, function() {
+              var $_ = ["ul", null];
+
+              completed.added.forEach(function(id) {
+                $_.push(React.createElement("li", null, React.createElement(
+                  "a",
+                  {href: "../../../roster/committer/" + id},
+                  id
+                )))
+              });
+
+              return $_
+            }()))
+          };
+
+          if (completed.removed && completed.removed.length != 0) {
+            $_.push(React.createElement("p", null, "Removed from PMC chairs"));
+
+            $_.push(React.createElement.apply(React, function() {
+              var $_ = ["ul", null];
+
+              completed.removed.forEach(function(id) {
+                $_.push(React.createElement("li", null, React.createElement(
+                  "a",
+                  {href: "../../../roster/committer/" + id},
+                  id
+                )))
+              });
+
+              return $_
+            }()))
+          };
+
+          if (completed.established && completed.established.length != 0) {
+            $_.push(React.createElement("p", null, "Established PMCs"));
+
+            $_.push(React.createElement.apply(React, function() {
+              var $_ = ["ul", null];
+
+              completed.established.forEach(function(pmc) {
+                $_.push(React.createElement("li", null, React.createElement(
+                  "a",
+                  {href: "../../../roster/committee/" + pmc},
+                  pmc
+                )))
+              });
+
+              return $_
+            }()))
+          };
+
+          if (completed.feedback_sent && completed.feedback_sent.length != 0) {
+            $_.push(React.createElement("p", null, "Sent feedback"));
+
+            $_.push(React.createElement.apply(React, function() {
+              var $_ = ["ul", null];
+
+              completed.feedback_sent.forEach(function(pmc) {
+                $_.push(React.createElement("li", null, React.createElement(
+                  Link,
+                  {text: pmc, href: pmc.replace(/\s+/g, "-")}
+                )))
+              });
+
+              return $_
+            }()))
+          }
+        };
+
+        return $_
+      }()),
+
+      React.createElement.apply(React, function() {
+        var $_ = ["section", null];
+        var minutes = Minutes.get(self.props.item.title);
+
+        if (minutes) {
+          $_.push(React.createElement("h3", null, "Minutes"));
+          $_.push(React.createElement("pre", {className: "comment"}, minutes))
+        };
+
+        return $_
+      }())
+    )
+  },
+
+  componentDidMount: function() {
+    this.componentDidUpdate()
+  },
+
+  // fetch secretary todos once the minutes are complete
+  componentDidUpdate: function() {
+    if (Minutes.complete && Todos.loading && !Todos.fetched) {
+      Todos.fetched = true;
+
+      retrieve("secretary-todos/" + Agenda.title, "json", function(todos) {
+        Todos.set(todos);
+        Todos.loading = false
+      })
+    }
+  }
+});
+
+//#######################################################################
+//                          Add, Remove chairs                          #
+//#######################################################################
+var TodoActions = React.createClass({
+  displayName: "TodoActions",
+
+  getInitialState: function() {
+    return {checked: {}, disabled: true, people: []}
+  },
+
+  // update on first update
+  // update on first update
+  componentDidMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  // update check marks based on current Todo list
+  componentWillReceiveProps: function($$props) {
+    var self = this;
+    var $people = this.state.people;
+    $people = Todos[$$props.action];
+
+    // uncheck people who were removed
+    for (var id in this.state.checked) {
+      if (!$people.some(function(person) {
+        return person.id == id
+      })) this.state.checked[id] = false
+    };
+
+    // check people who were added
+    $people.forEach(function(person) {
+      if (self.state.checked[person.id] == undefined) {
+        if (!person.resolution || Minutes.get(person.resolution) != "tabled") {
+          self.state.checked[person.id] = true
+        }
+      }
+    });
+
+    this.refresh();
+    this.setState({people: $people})
+  },
+
+  refresh: function() {
+    // disable button if nobody is checked
+    var disabled = true;
+
+    for (var id in this.state.checked) {
+      if (this.state.checked[id]) disabled = false
+    };
+
+    this.setState({disabled: disabled});
+    this.forceUpdate()
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = ["span", null];
+
+      if (self.props.action == "add") {
+        $_.push(React.createElement(
+          "p",
+          null,
+          "Add to pmc-chairs and email welcome message:"
+        ))
+      } else {
+        $_.push(React.createElement("p", null, "Remove from pmc-chairs:"))
+      };
+
+      $_.push(React.createElement.apply(React, function() {
+        var $_ = ["ul", {className: "checklist"}];
+
+        self.state.people.forEach(function(person) {
+          $_.push(React.createElement.apply(React, function() {
+            var $_ = ["li", null];
+
+            $_.push(React.createElement("input", {
+              type: "checkbox",
+              checked: self.state.checked[person.id],
+
+              onChange: function() {
+                self.state.checked[person.id] = !self.state.checked[person.id];
+                self.refresh()
+              }
+            }));
+
+            $_.push(React.createElement(
+              "a",
+              {href: "/roster/committer/" + person.id},
+              person.id
+            ));
+
+            $_.push(" (" + person.name + ")");
+            var resolution;
+
+            if (self.props.action == "add" && person.resolution) {
+              resolution = Minutes.get(person.resolution);
+
+              if (resolution) {
+                $_.push(" - ");
+
+                $_.push(React.createElement(
+                  Link,
+                  {text: resolution, href: Todos.link(person.resolution)}
+                ))
+              }
+            };
+
+            return $_
+          }()))
+        });
+
+        return $_
+      }()));
+
+      $_.push(React.createElement(
+        "button",
+
+        {
+          className: "checklist btn btn-default",
+          disabled: self.state.disabled,
+          onClick: self.submit
+        },
+
+        "Submit"
+      ));
+
+      return $_
+    }())
+  },
+
+  submit: function() {
+    var self = this;
+    this.setState({disabled: true});
+    var data = {};
+    data[this.props.action] = this.state.checked;
+
+    post("secretary-todos/" + Agenda.title, data, function(todos) {
+      self.setState({disabled: false});
+      Todos.set(todos)
+    })
+  }
+});
+
+//#######################################################################
+//                          Establish actions                           #
+//#######################################################################
+var EstablishActions = React.createClass({
+  displayName: "EstablishActions",
+
+  getInitialState: function() {
+    return {checked: {}, disabled: true, podlings: []}
+  },
+
+  componentDidMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  // update check marks based on current Todo list
+  componentWillReceiveProps: function($$props) {
+    var self = this;
+    var $podlings = this.state.podlings;
+    $podlings = Todos.establish;
+
+    // uncheck podlings that were removed
+    for (var name in this.state.checked) {
+      if (!$podlings.some(function(podling) {
+        return podling.name == name
+      })) this.state.checked[name] = false
+    };
+
+    // check podlings that were added
+    $podlings.forEach(function(podling) {
+      if (self.state.checked[podling.name] == undefined) {
+        if (!podling.resolution || Minutes.get(podling.resolution) != "tabled") {
+          self.state.checked[podling.name] = true
+        }
+      }
+    });
+
+    this.refresh();
+    this.setState({podlings: $podlings})
+  },
+
+  refresh: function() {
+    // disable button if nobody is checked
+    var disabled = true;
+
+    for (var id in this.state.checked) {
+      if (this.state.checked[id]) disabled = false
+    };
+
+    this.setState({disabled: disabled});
+    this.forceUpdate()
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement(
+      "span",
+      null,
+
+      React.createElement("p", null, React.createElement(
+        "a",
+        {href: "https://infra.apache.org/officers/tlpreq"},
+        "Establish pmcs:"
+      )),
+
+      React.createElement.apply(React, function() {
+        var $_ = ["ul", {className: "checklist"}];
+
+        self.state.podlings.forEach(function(podling) {
+          $_.push(React.createElement.apply(React, function() {
+            var $_ = ["li", null];
+
+            $_.push(React.createElement("input", {
+              type: "checkbox",
+              checked: self.state.checked[podling.name],
+
+              onChange: function() {
+                self.state.checked[podling.name] = !self.state.checked[podling.name];
+                self.refresh()
+              }
+            }));
+
+            $_.push(React.createElement("span", null, podling.name));
+            var resolution = Minutes.get(podling.resolution);
+
+            if (resolution) {
+              $_.push(" - ");
+
+              $_.push(React.createElement(
+                Link,
+                {text: resolution, href: Todos.link(podling.resolution)}
+              ))
+            };
+
+            return $_
+          }()))
+        });
+
+        return $_
+      }()),
+
+      React.createElement(
+        "button",
+
+        {
+          className: "checklist btn btn-default",
+          disabled: this.state.disabled,
+          onClick: this.submit
+        },
+
+        "Submit"
+      )
+    )
+  },
+
+  submit: function() {
+    var self = this;
+    this.setState({disabled: true});
+    var data = {establish: this.state.checked};
+
+    post("secretary-todos/" + Agenda.title, data, function(todos) {
+      self.setState({disabled: false});
+      Todos.set(todos)
+    })
+  }
+});
+
+//#######################################################################
+//                      Reminder to draft feedback                      #
+//#######################################################################
+var FeedbackReminder = React.createClass({
+  displayName: "FeedbackReminder",
+
+  render: function() {
+    return React.createElement(
+      "span",
+      null,
+      React.createElement("p", null, "Draft feedback:"),
+
+      React.createElement.apply(React, function() {
+        var $_ = ["ul", {className: "list-group row"}];
+
+        Todos.feedback.forEach(function(pmc) {
+          $_.push(React.createElement(
+            "li",
+            {className: "list-group-item col-xs-6 col-sm-4 col-md-3 col-lg-2"},
+
+            React.createElement(
+              Link,
+              {text: pmc, href: pmc.replace(/\s+/g, "-")}
+            )
+          ))
+        });
+
+        return $_
+      }()),
+
+      React.createElement(
+        "button",
+
+        {className: "checklist btn btn-default", onClick: function() {
+          window.location.href = "feedback"
+        }},
+
+        "Submit"
+      )
+    )
+  }
+});
+
+//#######################################################################
+//                             shared state                             #
+//#######################################################################
+function Todos() {};
+
+Todos.set = function(value) {
+  for (var attr in value) {
+    Todos[attr] = value[attr]
+  }
+};
+
+// find corresponding agenda item
+Todos.link = function(title) {
+  var link = null;
+
+  Agenda.index.forEach(function(item) {
+    if (item.title == title) link = item.href
+  });
+
+  return link
+};
+
+//
+// Blank canvas shown during bootstrapping
+//
+var BootStrapPage = React.createClass({
+  displayName: "BootStrapPage",
+
+  render: function() {
+    return React.createElement("p", null, "")
+  }
+});
+
+//
+// Overall Agenda page: simple table with one row for each item in the index
+//
+var Index = React.createClass({
+  displayName: "Index",
+
+  render: function() {
+    return React.createElement(
+      "span",
+      null,
+
+      React.createElement(
+        "header",
+        null,
+        React.createElement("h1", null, "ASF Board Agenda")
+      ),
+
+      React.createElement(
+        "table",
+        {className: "table-bordered"},
+
+        React.createElement(
+          "thead",
+          null,
+          React.createElement("th", null, "Attach"),
+          React.createElement("th", null, "Title"),
+          React.createElement("th", null, "Owner"),
+          React.createElement("th", null, "Shepherd")
+        ),
+
+        React.createElement.apply(React, function() {
+          var $_ = ["tbody", null];
+
+          Agenda.index.forEach(function(row) {
+            $_.push(React.createElement(
+              "tr",
+              {className: row.color},
+              React.createElement("td", null, row.attach),
+
+              React.createElement(
+                "td",
+                null,
+                React.createElement(Link, {text: row.title, href: row.href})
+              ),
+
+              React.createElement("td", null, row.owner),
+
+              React.createElement.apply(React, function() {
+                var $_ = ["td", null];
+
+                if (row.shepherd) {
+                  $_.push(React.createElement(
+                    Link,
+                    {text: row.shepherd, href: "shepherd/" + row.shepherd.split(" ")[0]}
+                  ))
+                };
+
+                return $_
+              }())
+            ))
+          });
+
+          return $_
+        }())
+      )
+    )
+  }
+});
+
+//
+// A two section representation of an agenda item (typically a PMC report),
+// where the two sections will show up as two columns on wide enough windows.
+//
+// The first section contains the item text, with a missing indicator if
+// the report isn't present.  It also contains an inline copy of draft
+// minutes for agenda items in section 3.
+//
+// The second section contains posted comments, pending comments, and
+// action items associated with this agenda item.
+//
+// Filters may be used to highlight or hypertext link portions of the text.
+//
+var Report = React.createClass({
+  displayName: "Report",
+
+  getInitialState: function() {
+    return {}
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement(
+      "section",
+      {className: "flexbox"},
+
+      React.createElement.apply(React, function() {
+        var $_ = ["section", null];
+
+        if (self.props.item.warnings) {
+          $_.push(React.createElement.apply(React, function() {
+            var $_ = ["ul", {className: "missing"}];
+
+            self.props.item.warnings.forEach(function(warning) {
+              $_.push(React.createElement("li", null, warning))
+            });
+
+            return $_
+          }()))
+        };
+
+        $_.push(React.createElement.apply(React, function() {
+          var $_ = ["pre", {className: "report"}];
+
+          if (self.props.item.text) {
+            $_.push(React.createElement(
+              Text,
+              {raw: self.props.item.text, filters: self.state.filters}
+            ))
+          } else if (self.props.item.missing) {
+            $_.push(React.createElement(
+              "p",
+              null,
+              React.createElement("em", null, "Missing")
+            ))
+          } else {
+            $_.push(React.createElement(
+              "p",
+              null,
+              React.createElement("em", null, "Empty")
+            ))
+          };
+
+          return $_
+        }()));
+
+        if ((self.props.item.missing || self.props.item.comments) && self.props.item.mail_list) {
+          $_.push(React.createElement(
+            "section",
+            {className: "reminder"},
+            React.createElement(Email, {item: self.props.item})
+          ))
+        };
+
+        if (self.props.item.minutes) {
+          $_.push(React.createElement(
+            "pre",
+            {className: "comment"},
+
+            React.createElement(
+              Text,
+              {raw: self.props.item.minutes, filters: [hotlink]}
+            )
+          ))
+        };
+
+        return $_
+      }()),
+
+      React.createElement(
+        "section",
+        null,
+        React.createElement(AdditionalInfo, {item: this.props.item}),
+
+        React.createElement(
+          "div",
+          {className: "report-info"},
+          React.createElement("h4", null, "Report Info"),
+          React.createElement(Info, {item: this.props.item})
+        )
+      )
+    )
+  },
+
+  // ensure componentWillReceiveProps is called on before first rendering
+  componentWillMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  componentWillReceiveProps: function($$props) {
+    var $filters = this.state.filters;
+
+    // determine what text filters to run
+    $filters = [
+      this.linebreak,
+      this.todo,
+      hotlink,
+      this.privates,
+      this.jira
+    ];
+
+    if ($$props.item.title == "Call to order") {
+      $filters = [this.localtime, hotlink]
+    };
+
+    if ($$props.item.people) $filters.push(this.names);
+
+    if ($$props.item.title == "President") {
+      $filters.push(this.president_attachments)
+    };
+
+    // special processing for Minutes from previous meetings
+    var date;
+
+    if (/^3[A-Z]$/.test($$props.item.attach)) {
+      $filters = [this.linkMinutes];
+      date = ($$props.item.text.match(/board_minutes_(\d+_\d+_\d+)\.txt/) || [])[1];
+
+      if (date && typeof $$props.item.minutes === 'undefined' && typeof XMLHttpRequest !== 'undefined' && Server.drafts.indexOf("board_minutes_" + date + ".txt") != -1) {
+        $$props.item.minutes = "";
+
+        retrieve("minutes/" + date, "text", function(minutes) {
+          $$props.item.minutes = minutes
+        })
+      }
+    };
+
+    this.setState({filters: $filters})
+  },
+
+  //
+  //## filters
+  //
+  // Highlight todos
+  todo: function(text) {
+    return text.replace(/TODO/g, "<span class=\"missing\">TODO</span>")
+  },
+
+  // Break long lines, treating HTML Entities (like &amp;) as one character
+  linebreak: function(text) {
+    // find long, breakable lines
+    var regex = /(\&\w+;|.){80}.+/g;
+    var result = null;
+    var indicies = [];
+
+    while (result = regex.exec(text)) {
+      var line = result[0];
+      if (line.replace(/\&\w+;/g, ".").length < 80) break;
+      var lastspace = /^.*\s\S/.exec(line);
+
+      if (lastspace && lastspace[0].replace(/\&\w+;/g, ".").length - 1 > 40) {
+        indicies.unshift([line, result.index])
+      }
+    };
+
+    // reflow each line found
+    indicies.forEach(function(info) {
+      var line = info[0];
+      var index = info[1];
+      var prefix = /^\W*/.exec(line)[0];
+      var indent = new Array(prefix.length + 1).join(" ");
+
+      var replacement = "<span class=\"hilite\" title=\"reflowed\">" + prefix + Flow.text(
+        line.slice(prefix.length, line.length),
+        indent
+      ).replace(/\n/g, "\n" + indent) + "</span>";
+
+      text = text.slice(0, index) + replacement + text.slice(index + line.length)
+    });
+
+    return text
+  },
+
+  // Convert start time to local time on Call to order page
+  localtime: function(text) {
+    var self = this;
+
+    return text.replace(
+      /\n(\s+)(Other Time Zones:.*)/,
+
+      function(match, spaces, text) {
+        var localtime = new Date(self.props.item.timestamp).toLocaleString();
+        return ("\n" + spaces + "<span class='hilite'>") + ("Local Time: " + localtime + "</span>" + spaces + text)
+      }
+    )
+  },
+
+  // replace ids with committer links
+  names: function(text) {
+    var roster = "/roster/committer/";
+
+    for (var id in this.props.item.people) {
+      var person = this.props.item.people[id];
+
+      // email addresses in 'Establish' resolutions and (ids) everywhere
+      text = text.replace(
+        new RegExp("(\\(|&lt;)(" + id + ")( at |@|\\))", "g"),
+
+        function(m, pre, id, post) {
+          if (person.icla) {
+            return (post == ")" && person.member ? pre + "<b><a href='" + roster + id + "'>" + id + "</a></b>" + post : pre + "<a href='" + roster + id + "'>" + id + "</a>" + post)
+          } else {
+            return (pre + "<a class='missing' href='" + roster + "?q=" + person.name + "'>") + (id + "</a>" + post)
+          }
+        }
+      );
+
+      // names
+      var pattern;
+
+      if (person.icla || this.props.item.title == "Roll Call") {
+        pattern = escapeRegExp(person.name).replace(/ +/g, "\\s+");
+
+        if (typeof person.member !== 'undefined') {
+          text = text.replace(new RegExp(pattern, "g"), function(match) {
+            return "<a href='" + roster + id + "'>" + match + "</a>"
+          })
+        } else {
+          text = text.replace(new RegExp(pattern, "g"), function(match) {
+            return "<a href='" + roster + "?q=" + person.name + "'>" + match + "</a>"
+          })
+        }
+      };
+
+      // highlight potentially misspelled names
+      var names, iclas, ok;
+
+      if (person.icla && person.icla != person.name) {
+        names = person.name.split(/\s+/);
+        iclas = person.icla.split(/\s+/);
+        ok = false;
+
+        ok = ok || names.every(function(part) {
+          return iclas.some(function(icla) {
+            return icla.indexOf(part) != -1
+          })
+        });
+
+        ok = ok || iclas.every(function(part) {
+          return names.some(function(name) {
+            return name.indexOf(part) != -1
+          })
+        });
+
+        if (/^Establish/.test(this.props.item.title) && !ok) {
+          text = text.replace(
+            new RegExp(escapeRegExp(id + "'>" + person.name), "g"),
+            ("?q=" + encodeURIComponent(person.name) + "'>") + ("<span class='commented'>" + person.name + "</span>")
+          )
+        } else {
+          text = text.replace(
+            new RegExp(escapeRegExp(person.name), "g"),
+            "<a href='" + roster + id + "'>" + person.name + "</a>"
+          )
+        }
+      };
+
+      // put members names in bold
+      if (person.member) {
+        pattern = escapeRegExp(person.name).replace(/ +/g, "\\s+");
+
+        text = text.replace(new RegExp(pattern, "g"), function(match) {
+          return "<b>" + match + "</b>"
+        })
+      }
+    };
+
+    // treat any unmatched names in Roll Call as misspelled
+    if (this.props.item.title == "Roll Call") {
+      text = text.replace(
+        /(\n\s{4})([A-Z].*)/g,
+
+        function(match, space, name) {
+          return space + "<a class='commented' href='" + roster + "?q=" + name + "'>" + name + "</a>"
+        }
+      )
+    };
+
+    // highlight any non-apache.org email addresses in establish resolutions
+    if (/^Establish/.test(this.props.item.title)) {
+      text = text.replace(
+        /(&lt;|\()[-.\w]+@(([-\w]+\.)+\w+)(&gt;|\))/g,
+
+        function(match) {
+          return (/@apache\.org/.test(match) ? match : "<span class=\"commented\" title=\"non @apache.org email address\">" + match + "</span>")
+        }
+      )
+    };
+
+    // highlight mis-spelling of previous and proposed chair names
+    if (this.props.item.title.substring(0, 6) == "Change" && /\(\w[-_.\w]+\)/.test(text)) {
+      text = text.replace(
+        /heretofore\s+appointed\s+(\w(\s|.)*?)\s+\(/,
+
+        function(text, name) {
+          return text.replace(name, "<span class='hilite'>" + name + "</span>")
+        }
+      );
+
+      text = text.replace(
+        /chosen\sto\s+recommend\s+(\w(\s|.)*?)\s+\(/,
+
+        function(text, name) {
+          return text.replace(name, "<span class='hilite'>" + name + "</span>")
+        }
+      )
+    };
+
+    return text
+  },
+
+  // link to board minutes
+  linkMinutes: function(text) {
+    text = text.replace(
+      /board_minutes_(\d+)_\d+_\d+\.txt/g,
+
+      function(match, year) {
+        var link;
+
+        if (Server.drafts.indexOf(match) != -1) {
+          link = "https://svn.apache.org/repos/private/foundation/board/" + match
+        } else {
+          link = "http://apache.org/foundation/records/minutes/" + year + "/" + match
+        };
+
+        return "<a href='" + link + "'>" + match + "</a>"
+      }
+    );
+
+    return text
+  },
+
+  // highlight private sections - these sections appear in the agenda but
+  // will be removed when the minutes are produced (see models/minutes.rb)
+  privates: function(text) {
+    // inline <private>...</private> sections (and preceding spaces and tabs)
+    // where the <private> and </private> are on the same line.
+    var private_inline = new RegExp("([ \\t]*&lt;private&gt;.*?&lt;\\/private&gt;)", "ig");
+
+    // block of lines (and preceding whitespace) where the first line starts
+    // with <private> and the last line ends </private>.
+    var private_lines = new RegExp("^([ \\t]*&lt;private&gt;(?:\\n|.)*?&lt;/private&gt;)(\\s*)$", "mig");
+
+    // return the text with private sections marked with class private
+    return text.replace(
+      private_inline,
+      "<span class=\"private\">$1</span>"
+    ).replace(private_lines, "<div class=\"private\">$1</div>")
+  },
+
+  // expand president's attachments
+  president_attachments: function(text) {
+    var match = text.match(/Additionally, please see Attachments (\d) through (\d)/);
+    var agenda;
+
+    if (match) {
+      agenda = Agenda.index;
+
+      for (var i = 0; i < agenda.length; i++) {
+        if (!/^\d$/.test(agenda[i].attach)) continue;
+
+        if (agenda[i].attach >= match[1] && agenda[i].attach <= match[2]) {
+          text += ("\n  " + agenda[i].attach + ". ") + ("<a " + (agenda[i].text.length == 0 ? "class=\"pres-missing\" " : "")) + ("href='" + agenda[i].href + "'>" + agenda[i].title + "</a>")
+        }
+      }
+    };
+
+    return text
+  },
+
+  // hotlink to JIRA issues
+  jira: function(text) {
+    var jira_issue = /(^|\s|\(|\[)([A-Z][A-Z0-9]+)-([1-9][0-9]*)(\.(\D|$)|[,;:\s)\]]|$)/g;
+
+    text = text.replace(jira_issue, function(m, pre, name, issue, post) {
+      if (JIRA.find(name)) {
+        return (pre + "<a target='_self' ") + ("href='https://issues.apache.org/jira/browse/" + name + "-" + issue + "'>") + (name + "-" + issue + "</a>" + post)
+      } else {
+        return pre + name + "-" + issue + post
+      }
+    });
+
+    return text
+  }
+});
+
+//
+// Action items.  Link to PMC reports when possible, highlight missing
+// action item status updates.
+//
+var ActionItems = React.createClass({
+  displayName: "ActionItems",
+
+  getInitialState: function() {
+    return {disabled: false}
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = ["span", null];
+      var first = true;
+      var updates = Object.keys(Pending.status);
+
+      $_.push(React.createElement.apply(React, function() {
+        var $_ = ["section", {className: "flexbox"}];
+
+        $_.push(React.createElement.apply(React, function() {
+          var $_ = ["pre", {className: "report"}];
+
+          self.props.item.actions.forEach(function(action) {
+            // skip actions that don't match the filter
+            var match;
+
+            if (self.props.filter) {
+              match = true;
+
+              for (var key in self.props.filter) {
+                match = match && (action[key] == self.props.filter[key])
+              };
+
+              if (!match) return
+            };
+
+            // space between items and add help info on top
+            if (first) {
+              if (!self.props.filter && !Minutes.complete) {
+                $_.push(React.createElement(
+                  "p",
+                  {className: "alert-info"},
+                  "Click on Status to update"
+                ))
+              };
+
+              first = false
+            } else {
+              $_.push("\n")
+            };
+
+            // action owner and text
+            $_.push("* " + action.owner + ": " + action.text + "\n      ");
+            var item, agenda;
+
+            if (action.pmc && !(self.props.filter && self.props.filter.title)) {
+              $_.push("[ ");
+
+              // if there is an associated PMC and that PMC is on this month's
+              // agenda, link to the current report, if reporting this month
+              item = Agenda.find(action.pmc);
+
+              if (item) {
+                $_.push(React.createElement(
+                  Link,
+                  {className: item.color, text: action.pmc, href: item.href}
+                ))
+              } else if (action.pmc) {
+                $_.push(React.createElement("span", {className: "blank"}, action.pmc))
+              };
+
+              // link to the original report
+              if (action.date) {
+                $_.push(" ");
+                agenda = "board_agenda_" + action.date.replace(/\-/g, "_") + ".txt";
+
+                if (Server.agendas.indexOf(agenda) != -1) {
+                  $_.push(React.createElement(
+                    "a",
+                    {href: "../" + action.date + "/" + action.pmc.replace(/\W/g, "-")},
+                    action.date
+                  ))
+                } else {
+                  $_.push(React.createElement(
+                    "a",
+
+                    {href: "/board/minutes/" + action.pmc.replace(/\W/g, "_") + ("#minutes_" + action.date.replace(
+                      /\-/g,
+                      "_"
+                    ))},
+
+                    action.date
+                  ))
+                }
+              };
+
+              $_.push(" ]\n      ")
+            } else if (action.date) {
+              $_.push("[ " + action.date + " ]\n      ")
+            };
+
+            // launch edit dialog when there is a click on the status
+            var attrs = {onClick: self.updateStatus, className: "clickable"};
+            if (Minutes.complete) attrs = {};
+
+            // copy action properties to data attributes
+            for (var name in action) {
+              attrs["data-" + name] = action[name]
+            };
+
+            // include pending updates
+            var pending = Pending.find_status(action);
+            if (pending) attrs["data-status"] = pending.status;
+
+            $_.push(React.createElement.apply(React, function() {
+              var $_ = ["span", attrs];
+
+              // highlight missing action item status updates
+              if (pending) {
+                $_.push(React.createElement("span", null, "Status: "));
+
+                pending.status.split("\n").forEach(function(line) {
+                  match = line.match(/^( *)(.*)/);
+                  $_.push(React.createElement("span", null, match[1]));
+
+                  $_.push(React.createElement(
+                    "em",
+                    {className: "commented"},
+                    match[2] + "\n"
+                  ))
+                })
+              } else if (action.status == "") {
+                $_.push(React.createElement(
+                  "span",
+                  {className: "missing"},
+                  "Status:"
+                ));
+
+                $_.push("\n")
+              } else {
+                $_.push(React.createElement(
+                  Text,
+                  {raw: "Status: " + action.status + "\n", filters: [hotlink]}
+                ))
+              };
+
+              return $_
+            }()))
+          });
+
+          if (first) {
+            $_.push(React.createElement(
+              "p",
+              null,
+              React.createElement("em", null, "Empty")
+            ))
+          };
+
+          return $_
+        }()));
+
+        if (!first) {
+          // Update action item (hidden form)
+          $_.push(React.createElement(
+            ModalDialog,
+            {id: "updateStatusForm", color: "commented"},
+            React.createElement("h4", null, "Update Action Item"),
+
+            React.createElement.apply(React, function() {
+              var $_ = ["p", null];
+
+              $_.push(React.createElement(
+                "span",
+                null,
+                self.state.owner + ": " + self.state.text
+              ));
+
+              if (self.state.pmc) {
+                $_.push(" [ ");
+
+                if (self.state.pmc) {
+                  $_.push(React.createElement("span", null, " " + self.state.pmc))
+                };
+
+                if (self.state.date) {
+                  $_.push(React.createElement("span", null, " " + self.state.date))
+                };
+
+                $_.push(" ]")
+              };
+
+              return $_
+            }()),
+
+            React.createElement("textarea", {
+              ref: "statusText",
+              label: "Status:",
+              value: self.state.status,
+              rows: 5,
+
+              onChange: function(event) {
+                self.setState({status: event.target.value})
+              }
+            }),
+
+            React.createElement(
+              "button",
+
+              {
+                className: "btn-default",
+                "data-dismiss": "modal",
+                disabled: self.state.disabled
+              },
+
+              "Cancel"
+            ),
+
+            React.createElement(
+              "button",
+
+              {
+                className: "btn-primary",
+                onClick: self.save,
+                disabled: self.state.disabled || (self.state.baseline == self.state.status)
+              },
+
+              "Save"
+            )
+          ))
+        };
+
+        return $_
+      }()));
+
+      // Action Items Captured During the Meeting
+      var captured;
+
+      if (self.props.item.title == "Action Items") {
+        captured = [];
+
+        Minutes.actions.forEach(function(action) {
+          var match;
+
+          if (self.props.filter) {
+            match = true;
+
+            for (var key in self.props.filter) {
+              match = match && (action[key] == self.props.filter[key])
+            };
+
+            if (!match) return
+          };
+
+          captured.push(action)
+        });
+
+        if (captured.length != 0) {
+          $_.push(React.createElement(
+            "section",
+            null,
+
+            React.createElement(
+              "h3",
+              null,
+              "Action Items Captured During the Meeting"
+            ),
+
+            React.createElement.apply(React, function() {
+              var $_ = ["pre", {className: "comment"}];
+
+              captured.forEach(function(action) {
+                // skip actions that don't match the filter
+                var match;
+
+                if (self.props.filter) {
+                  match = true;
+
+                  for (var key in self.props.filter) {
+                    match = match && (action[key] == self.props.filter[key])
+                  };
+
+                  if (!match) return
+                };
+
+                $_.push("* " + action.owner + ": " + action.text.replace(
+                  /\n/g,
+                  "\n        "
+                ) + "\n");
+
+                $_.push("      [ ");
+
+                if (action.item) {
+                  $_.push(React.createElement(Link, {
+                    className: action.item.color,
+                    text: action.item.title,
+                    href: action.item.href
+                  }))
+                };
+
+                $_.push(" " + Agenda.title + " ]\n\n")
+              });
+
+              return $_
+            }())
+          ))
+        }
+      };
+
+      return $_
+    }())
+  },
+
+  // autofocus on action status in update action form
+  componentDidMount: function() {
+    var self = this;
+
+    jQuery("#updateStatusForm").on("shown.bs.modal", function() {
+      self.refs.statusText.focus()
+    })
+  },
+
+  // launch update status form when status text is clicked
+  updateStatus: function(event) {
+    var parent = event.target.parentNode;
+
+    // construct action from data attributes
+    var action = {};
+
+    for (var i = 0; i < parent.attributes.length; i++) {
+      var attr = parent.attributes[i];
+
+      if (attr.name.substring(0, 5) == "data-") {
+        action[attr.name.slice(5, attr.name.length)] = attr.value
+      }
+    };
+
+    // unindent action
+    action.status = action.status.replace(/\n {14}/g, "\n");
+
+    // set baseline to current value
+    action.baseline = action.status;
+
+    // show dialog
+    jQuery("#updateStatusForm").modal("show");
+
+    // update state
+    this.setState(action)
+  },
+
+  // when save button is pushed, post update and dismiss modal when complete
+  save: function(event) {
+    var self = this;
+
+    var data = {
+      agenda: Agenda.file,
+      owner: this.state.owner,
+      text: this.state.text,
+      pmc: this.state.pmc,
+      date: this.state.date,
+      status: this.state.status
+    };
+
+    this.setState({disabled: true});
+
+    post("status", data, function(pending) {
+      jQuery(self.refs.updateStatusForm).modal("hide");
+      self.setState({disabled: false});
+      Pending.load(pending)
+    })
+  }
+});
+
+//
+// Search component: 
+//  * prompt for search 
+//  * display matching paragraphs from agenda, highlighting search strings
+//  * keep query string in window location URL in synch
+//
+var Search = React.createClass({
+  displayName: "Search",
+
+  // initialize query text based on data passed to the component
+  getInitialState: function() {
+    return {text: this.props.item.query || ""}
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = ["span", null];
+
+      // search input field
+      $_.push(React.createElement(
+        "div",
+        {className: "search"},
+        React.createElement("label", {htmlFor: "search_text"}, "Search:"),
+
+        React.createElement("input", {
+          id: "search-text",
+          autoFocus: "autofocus",
+          value: self.state.text,
+          onInput: self.input,
+
+          onChange: function(event) {
+            self.setState({text: event.target.value})
+          }
+        })
+      ));
+
+      var matches, text;
+
+      if (self.state.text.length > 2) {
+        matches = false;
+        text = self.state.text.toLowerCase();
+
+        Agenda.index.forEach(function(item) {
+          if (!item.text || item.text.toLowerCase().indexOf(text) == -1) return;
+          matches = true;
+
+          $_.push(React.createElement.apply(React, function() {
+            var $_ = ["section", null];
+
+            $_.push(React.createElement(
+              "h4",
+              null,
+              React.createElement(Link, {text: item.title, href: item.href})
+            ));
+
+            // highlight matching strings in paragraph
+            item.text.split(/\n\s*\n/).forEach(function(paragraph) {
+              if (paragraph.toLowerCase().indexOf(text) != -1) {
+                $_.push(React.createElement("pre", {
+                  className: "report",
+
+                  dangerouslySetInnerHTML: {__html: htmlEscape(paragraph).replace(
+                    new RegExp("(" + text + ")", "gi"),
+                    "<span class='hilite'>$1</span>"
+                  )}
+                }))
+              }
+            });
+
+            return $_
+          }()))
+        });
+
+        // if no sections were output, indicate 'no matches'
+        if (!matches) {
+          $_.push(React.createElement(
+            "p",
+            null,
+            React.createElement("em", null, "No matches")
+          ))
+        }
+      } else {
+        // start producing query results when input string has three characters
+        $_.push(React.createElement(
+          "p",
+          null,
+          "Please enter at least three characters"
+        ))
+      };
+
+      return $_
+    }())
+  },
+
+  // update text whenever input changes
+  input: function(event) {
+    this.setState({text: event.target.value})
+  },
+
+  componentDidMount: function() {
+    this.componentDidUpdate()
+  },
+
+  // replace history state on subsequent renderings
+  componentDidUpdate: function() {
+    var state = {path: "search", query: this.state.text};
+
+    if (state.query) {
+      history.replaceState(
+        state,
+        null,
+        "search?q=" + encodeURIComponent(this.state.text)
+      )
+    } else {
+      history.replaceState(state, null, "search")
+    }
+  }
+});
+
+//
+// A page showing all comments present across all agenda items
+// Conditionally hide comments previously marked as seen.
+//
+var Comments = React.createClass({
+  displayName: "Comments",
+
+  statics: {buttons: function() {
+    var buttons = [];
+
+    if (MarkSeen.undo || Agenda.index.some(function(item) {
+      return item.unseen_comments.length != 0
+    })) buttons.push({button: MarkSeen});
+
+    if (Pending.seen && Object.keys(Pending.seen).length != 0) {
+      buttons.push({button: ShowSeen})
+    };
+
+    return buttons
+  }},
+
+  getInitialState: function() {
+    return {showseen: false}
+  },
+
+  toggleseen: function() {
+    this.setState({showseen: !this.state.showseen})
+  },
+
+  showseen: function() {
+    return this.state.showseen
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = ["span", null];
+      var found = false;
+
+      Agenda.index.forEach(function(item) {
+        if (item.comments.length == 0) return;
+        var visible = (self.state.showseen ? item.comments : item.unseen_comments);
+
+        if (visible.length != 0) {
+          found = true;
+
+          $_.push(React.createElement.apply(React, function() {
+            var $_ = ["section", null];
+
+            $_.push(React.createElement(
+              Link,
+              {className: "h4 " + item.color, text: item.title, href: item.href}
+            ));
+
+            visible.forEach(function(comment) {
+              $_.push(React.createElement("pre", {className: "comment"}, comment))
+            });
+
+            return $_
+          }()))
+        }
+      });
+
+      if (!found) {
+        $_.push(React.createElement.apply(React, function() {
+          var $_ = ["p", null];
+
+          if (Object.keys(Pending.seen).length == 0) {
+            $_.push(React.createElement("em", null, "No comments found"))
+          } else {
+            $_.push(React.createElement("em", null, "No new comments found"))
+          };
+
+          return $_
+        }()))
+      };
+
+      return $_
+    }())
+  }
+});
+
+var Help = React.createClass({
+  displayName: "Help",
+
+  render: function() {
+    var self = this;
+
+    return React.createElement(
+      "span",
+      null,
+      React.createElement("h3", null, "Keyboard shortcuts"),
+
+      React.createElement(
+        "dl",
+        {className: "dl-horizontal"},
+        React.createElement("dt", null, "left arrow"),
+        React.createElement("dd", null, "previous page"),
+        React.createElement("dt", null, "right arrow"),
+        React.createElement("dd", null, "next page"),
+        React.createElement("dt", null, "enter"),
+
+        React.createElement(
+          "dd",
+          null,
+          "On Shepherd and Queue pages, go to the first report listed"
+        ),
+
+        React.createElement("dt", null, "C"),
+        React.createElement("dd", null, "Scroll to comment section (if any)"),
+        React.createElement("dt", null, "I"),
+        React.createElement("dd", null, "Toggle Info dropdown"),
+        React.createElement("dt", null, "N"),
+        React.createElement("dd", null, "Toggle Navigation dropdown"),
+        React.createElement("dt", null, "A"),
+
+        React.createElement(
+          "dd",
+          null,
+          "Navigate to the overall agenda page"
+        ),
+
+        React.createElement("dt", null, "F"),
+        React.createElement("dd", null, "Show flagged items"),
+        React.createElement("dt", null, "M"),
+        React.createElement("dd", null, "Show missing items"),
+        React.createElement("dt", null, "Q"),
+        React.createElement("dd", null, "Show queued approvals/comments"),
+        React.createElement("dt", null, "S"),
+
+        React.createElement(
+          "dd",
+          null,
+          "Show shepherded items (and action items)"
+        ),
+
+        React.createElement("dt", null, "X"),
+
+        React.createElement(
+          "dd",
+          null,
+          "Set the topic (a.k.a. mark the spot)"
+        ),
+
+        React.createElement("dt", null, "?"),
+        React.createElement("dd", null, "Help (this page)")
+      ),
+
+      React.createElement("h3", null, "Color Legend"),
+
+      React.createElement(
+        "ul",
+        null,
+
+        React.createElement(
+          "li",
+          {className: "missing"},
+          "Report missing, rejected, or has formatting errors"
+        ),
+
+        React.createElement(
+          "li",
+          {className: "available"},
+          "Report present, not eligible for pre-reviews"
+        ),
+
+        React.createElement(
+          "li",
+          {className: "ready"},
+          "Report present, ready for (more) review(s)"
+        ),
+
+        React.createElement(
+          "li",
+          {className: "reviewed"},
+          "Report has sufficient pre-approvals"
+        ),
+
+        React.createElement(
+          "li",
+          {className: "commented"},
+          "Report has been flagged for discussion"
+        )
+      ),
+
+      React.createElement("h3", null, "Change Role"),
+
+      React.createElement.apply(React, function() {
+        var $_ = ["form", {id: "role"}];
+
+        ["Secretary", "Director", "Guest"].forEach(function(role) {
+          $_.push(React.createElement(
+            "div",
+            null,
+
+            React.createElement("input", {
+              type: "radio",
+              name: "role",
+              value: role.toLowerCase(),
+              checked: role.toLowerCase() == Server.role,
+              onChange: self.setRole
+            }),
+
+            role
+          ))
+        });
+
+        return $_
+      }())
+    )
+  },
+
+  setRole: function(event) {
+    Server.role = event.target.value;
+    Main.refresh()
+  }
+});
+
+//
+// A page showing all queued approvals and comments, as well as items
+// that are ready for review.
+//
+var Shepherd = React.createClass({
+  displayName: "Shepherd",
+
+  getInitialState: function() {
+    return {disabled: false, followup: []}
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = ["span", null];
+      var shepherd = self.props.item.shepherd.toLowerCase();
+      var actions = Agenda.find("Action-Items");
+
+      if (actions.actions.some(function(action) {
+        return action.owner == self.props.item.shepherd
+      })) {
+        $_.push(React.createElement("h2", null, "Action Items"));
+
+        $_.push(React.createElement(
+          ActionItems,
+          {item: actions, filter: {owner: self.props.item.shepherd}}
+        ))
+      };
+
+      $_.push(React.createElement("h2", null, "Committee Reports"));
+
+      // list agenda items associated with this shepherd
+      Agenda.index.forEach(function(item) {
+        var mine;
+
+        if (item.shepherd && item.shepherd.toLowerCase().substring(
+          0,
+          shepherd.length
+        ) == shepherd) {
+          $_.push(React.createElement(Link, {
+            className: "h3 " + item.color,
+            text: item.title,
+            href: "shepherd/queue/" + item.href
+          }));
+
+          $_.push(React.createElement(
+            AdditionalInfo,
+            {item: item, prefix: true}
+          ));
+
+          // flag action
+          if (item.missing || item.comments.length != 0) {
+            if (/^[A-Z]+$/.test(item.attach)) {
+              mine = (shepherd == Server.firstname ? "btn-primary" : "btn-link");
+
+              $_.push(React.createElement(
+                "div",
+                {className: "shepherd"},
+
+                React.createElement(
+                  "button",
+
+                  {
+                    className: "btn " + (mine || ""),
+                    "data-attach": item.attach,
+                    onClick: self.click,
+                    disabled: self.state.disabled
+                  },
+
+                  (item.flagged ? "unflag" : "flag")
+                ),
+
+                React.createElement(Email, {item: item})
+              ))
+            }
+          }
+        }
+      });
+
+      // list feedback items that may need to be followed up
+      var followup = [];
+
+      for (var title in self.state.followup) {
+        if (self.state.followup[title].count != 1) continue;
+        if (self.state.followup[title].shepherd != self.props.item.shepherd) continue;
+
+        if (Agenda.index.some(function(item) {
+          return item.title == title
+        })) continue;
+
+        self.state.followup[title].title = title;
+        followup.push(self.state.followup[title])
+      };
+
+      if (followup.length != 0) {
+        $_.push(React.createElement(
+          "h2",
+          null,
+          "Feedback that may require followup"
+        ));
+
+        followup.forEach(function(followup) {
+          var link = followup.title.replace(/[^a-zA-Z0-9]+/g, "-");
+
+          $_.push(React.createElement(
+            "a",
+
+            {
+              className: "h3 ready",
+              href: "../" + self.state.prior_date + "/" + link
+            },
+
+            followup.title
+          ));
+
+          splitComments(followup.comments).forEach(function(comment) {
+            $_.push(React.createElement("pre", {className: "comment"}, comment))
+          })
+        })
+      };
+
+      return $_
+    }())
+  },
+
+  // Fetch followup items
+  componentDidMount: function() {
+    var self = this;
+
+    // if cached, reuse
+    if (Shepherd.followup) {
+      this.setState({followup: Shepherd.followup});
+      return
+    };
+
+    // determine date of previous meeting
+    var prior_agenda = Server.agendas[Server.agendas.length - 2];
+    if (!prior_agenda) return;
+
+    var $prior_date = (prior_agenda.match(/\d+_\d+_\d+/) || [])[0].replace(
+      /_/g,
+      "-"
+    );
+
+    retrieve(
+      "../" + $prior_date + "/followup.json",
+      "json",
+
+      function(followup) {
+        Shepherd.followup = followup;
+        self.setState({followup: followup})
+      }
+    );
+
+    this.setState({prior_date: $prior_date})
+  },
+
+  click: function(event) {
+    var self = this;
+
+    var data = {
+      agenda: Agenda.file,
+      initials: Server.initials,
+      attach: event.target.getAttribute("data-attach"),
+      request: event.target.textContent
+    };
+
+    this.setState({disabled: true});
+
+    post("approve", data, function(pending) {
+      self.setState({disabled: false});
+      Pending.load(pending)
+    })
+  }
+});
+
+//
+// A page showing all queued approvals and comments, as well as items
+// that are ready for review.
+//
+var Queue = React.createClass({
+  displayName: "Queue",
+
+  statics: {buttons: function() {
+    var buttons = [{button: Refresh}];
+    if (Pending.count > 0) buttons.push({form: Commit});
+    return buttons
+  }},
+
+  getInitialState: function() {
+    return {}
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = ["div", {className: "col-xs-12"}];
+
+      if (Server.role == "director") {
+        // Approvals
+        $_.push(React.createElement("h4", null, "Approvals"));
+
+        $_.push(React.createElement.apply(React, function() {
+          var $_ = ["p", {className: "col-xs-12"}];
+
+          self.state.approvals.forEach(function(item, index) {
+            if (index > 0) $_.push(React.createElement("span", null, ", "));
+
+            $_.push(React.createElement(
+              Link,
+              {text: item.title, href: "queue/" + item.href}
+            ))
+          });
+
+          if (self.state.approvals.length == 0) {
+            $_.push(React.createElement("em", null, "None."))
+          };
+
+          return $_
+        }()));
+
+        // Unapproved
+        ["Unapprovals", "Flagged", "Unflagged"].forEach(function(section) {
+          var list = self.state[section.toLowerCase()];
+
+          if (list.length != 0) {
+            $_.push(React.createElement("h4", null, section));
+
+            $_.push(React.createElement.apply(React, function() {
+              var $_ = ["p", {className: "col-xs-12"}];
+
+              list.forEach(function(item, index) {
+                if (index > 0) $_.push(React.createElement("span", null, ", "));
+
+                $_.push(React.createElement(
+                  Link,
+                  {text: item.title, href: item.href}
+                ))
+              });
+
+              return $_
+            }()))
+          }
+        })
+      };
+
+      // Comments
+      $_.push(React.createElement("h4", null, "Comments"));
+
+      if (self.state.comments.length == 0) {
+        $_.push(React.createElement(
+          "p",
+          {className: "col-xs-12"},
+          React.createElement("em", null, "None.")
+        ))
+      } else {
+        $_.push(React.createElement.apply(React, function() {
+          var $_ = ["dl", {className: "dl-horizontal"}];
+
+          self.state.comments.forEach(function(item) {
+            $_.push(React.createElement(
+              "dt",
+              null,
+              React.createElement(Link, {text: item.title, href: item.href})
+            ));
+
+            $_.push(React.createElement.apply(React, function() {
+              var $_ = ["dd", null];
+
+              item.pending.split("\n\n").forEach(function(paragraph) {
+                $_.push(React.createElement("p", null, paragraph))
+              });
+
+              return $_
+            }()))
+          });
+
+          return $_
+        }()))
+      };
+
+      // Action Item Status updates
+      if (Pending.status.length != 0) {
+        $_.push(React.createElement("h4", null, "Action Items"));
+
+        $_.push(React.createElement.apply(React, function() {
+          var $_ = ["ul", null];
+
+          Pending.status.forEach(function(item) {
+            var text = item.text;
+
+            if (item.pmc || item.date) {
+              text += " [";
+              if (item.pmc) text += " " + item.pmc;
+              if (item.date) text += " " + item.date;
+              text += " ]"
+            };
+
+            $_.push(React.createElement("li", null, text))
+          });
+
+          return $_
+        }()))
+      };
+
+      // Ready
+      if (Server.role == "director" && self.state.ready.length != 0) {
+        $_.push(React.createElement(
+          "div",
+          {className: "row col-xs-12"},
+          React.createElement("hr")
+        ));
+
+        $_.push(React.createElement("h4", null, "Ready for review"));
+
+        $_.push(React.createElement.apply(React, function() {
+          var $_ = ["p", {className: "col-xs-12"}];
+
+          self.state.ready.forEach(function(item, index) {
+            if (index > 0) $_.push(React.createElement("span", null, ", "));
+
+            $_.push(React.createElement(Link, {
+              className: (index == 0 ? "default" : null),
+              text: item.title,
+              href: "queue/" + item.href
+            }))
+          });
+
+          return $_
+        }()))
+      };
+
+      return $_
+    }())
+  },
+
+  componentWillMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  // determine approvals, rejected, comments, and ready
+  componentWillReceiveProps: function($$props) {
+    var $approvals = [];
+    var $unapprovals = [];
+    var $flagged = [];
+    var $unflagged = [];
+    var $comments = [];
+    var $ready = [];
+
+    Agenda.index.forEach(function(item) {
+      if (Pending.comments[item.attach]) $comments.push(item);
+      var action = false;
+
+      if (Pending.approved.indexOf(item.attach) != -1) {
+        $approvals.push(item);
+        action = true
+      };
+
+      if (Pending.unapproved.indexOf(item.attach) != -1) {
+        $unapprovals.push(item);
+        action = true
+      };
+
+      if (Pending.flagged.indexOf(item.attach) != -1) {
+        $flagged.push(item);
+        action = true
+      };
+
+      if (Pending.unflagged.indexOf(item.attach) != -1) {
+        $unflagged.push(item);
+        action = true
+      };
+
+      if (!action && item.ready_for_review(Server.initials)) $ready.push(item)
+    });
+
+    this.setState({
+      unflagged: $unflagged,
+      unapprovals: $unapprovals,
+      ready: $ready,
+      flagged: $flagged,
+      comments: $comments,
+      approvals: $approvals
+    })
+  }
+});
+
+//
+// A page showing all flagged reports
+//
+var Flagged = React.createClass({
+  displayName: "Flagged",
+
+  render: function() {
+    return React.createElement.apply(React, function() {
+      var $_ = ["span", null];
+      var first = true;
+
+      Agenda.index.forEach(function(item) {
+        if (item.flagged_by || Pending.flagged.indexOf(item.attach) != -1) {
+          $_.push(React.createElement.apply(React, function() {
+            var $_ = ["h3", {className: item.color}];
+
+            $_.push(React.createElement(Link, {
+              className: (first ? "default" : null),
+              text: item.title,
+              href: "flagged/" + item.href
+            }));
+
+            first = false;
+
+            $_.push(React.createElement(
+              "span",
+              {className: "owner"},
+              " [" + item.owner + " / " + item.shepherd + "]"
+            ));
+
+            var flagged_by = Server.directors[item.flagged_by] || item.flagged_by;
+
+            $_.push(React.createElement(
+              "span",
+              {className: "owner"},
+              " flagged by: " + flagged_by
+            ));
+
+            return $_
+          }()));
+
+          $_.push(React.createElement(
+            AdditionalInfo,
+            {item: item, prefix: true}
+          ))
+        }
+      });
+
+      if (first) $_.push(React.createElement("em", {className: "comment"}, "None"));
+      return $_
+    }())
+  }
+});
+
+//
+// A page showing all flagged reports
+//
+var Missing = React.createClass({
+  displayName: "Missing",
+
+  getInitialState: function() {
+    return {checked: {}}
+  },
+
+  componentDidMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  // update check marks based on current Index
+  componentWillReceiveProps: function($$props) {
+    var self = this;
+
+    Agenda.index.forEach(function(item) {
+      if (typeof self.state.checked[item.title] === 'undefined') {
+        self.state.checked[item.title] = true
+      }
+    })
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = ["span", null];
+      var first = true;
+
+      Agenda.index.forEach(function(item) {
+        if (item.missing) {
+          $_.push(React.createElement.apply(React, function() {
+            var $_ = ["h3", {className: item.color}];
+
+            if (/^[A-Z]+/.test(item.attach)) {
+              $_.push(React.createElement("input", {
+                type: "checkbox",
+                name: "selected",
+                value: item.title,
+                checked: self.state.checked[item.title],
+
+                onChange: function() {
+                  self.state.checked[item.title] = !self.state.checked[item.title];
+                  self.forceUpdate()
+                }
+              }))
+            };
+
+            $_.push(React.createElement(Link, {
+              className: (first ? "default" : null),
+              text: item.title,
+              href: "flagged/" + item.href
+            }));
+
+            first = false;
+
+            $_.push(React.createElement(
+              "span",
+              {className: "owner"},
+              " [" + item.owner + " / " + item.shepherd + "]"
+            ));
+
+            var flagged_by;
+
+            if (item.flagged_by) {
+              flagged_by = Server.directors[item.flagged_by] || item.flagged_by;
+
+              $_.push(React.createElement(
+                "span",
+                {className: "owner"},
+                " flagged by: " + flagged_by
+              ))
+            };
+
+            return $_
+          }()));
+
+          $_.push(React.createElement(
+            AdditionalInfo,
+            {item: item, prefix: true}
+          ))
+        }
+      });
+
+      if (first) $_.push(React.createElement("em", {className: "comment"}, "None"));
+      return $_
+    }())
+  }
+});
+
+//
+// Overall Agenda page: simple table with one row for each item in the index
+//
+var Backchannel = React.createClass({
+  displayName: "Backchannel",
+  statics: {buttons: function() {return [{button: Message}]}},
+
+  // render a list of messages
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = ["span", null];
+
+      $_.push(React.createElement(
+        "header",
+        null,
+        React.createElement("h1", null, "Agenda Backchannel")
+      ));
+
+      // convert date into a localized string
+      var datefmt = function(timestamp) {
+        return new Date(timestamp).toLocaleDateString(
+          {},
+          {month: "short", day: "numeric", year: "numeric"}
+        )
+      };
+
+      var i;
+
+      if (Chat.log.length == 0) {
+        if (Chat.backlog_fetched) {
+          $_.push(React.createElement("em", null, "No messages found."))
+        } else {
+          $_.push(React.createElement("em", null, "Loading messages"))
+        }
+      } else {
+        i = 0;
+
+        // group messages by date
+        while (i < Chat.log.length) {
+          var date = datefmt(Chat.log[i].timestamp);
+
+          if (i != 0 || date != datefmt(new Date().valueOf())) {
+            $_.push(React.createElement("h5", {className: "chatlog"}, date))
+          };
+
+          // group of messages that share the same (local) date
+          $_.push(React.createElement.apply(React, function() {
+            var $_ = ["dl", {className: "chatlog"}];
+
+            while (i < Chat.log.length) {
+              var message = Chat.log[i];
+              if (date != datefmt(message.timestamp)) break;
+
+              $_.push(React.createElement(
+                "dt",
+
+                {
+                  className: message.type,
+                  key: "t" + message.timestamp,
+                  title: new Date(message.timestamp).toLocaleTimeString()
+                },
+
+                message.user
+              ));
+
+              $_.push(React.createElement.apply(React, function() {
+                var $_ = [
+                  "dd",
+                  {className: message.type, key: "d" + message.timestamp}
+                ];
+
+                if (message.link) {
+                  $_.push(React.createElement(
+                    Link,
+                    {text: message.text, href: message.link}
+                  ))
+                } else {
+                  $_.push(React.createElement(
+                    Text,
+                    {raw: message.text, filters: [hotlink, self.mention]}
+                  ))
+                };
+
+                return $_
+              }()));
+
+              i++
+            };
+
+            return $_
+          }()))
+        }
+      };
+
+      return $_
+    }())
+  },
+
+  // highlight mentions of my id
+  mention: function(text) {
+    return text.replace(
+      new RegExp("<.*?>|\\b(" + Server.userid + ")\\b", "g"),
+
+      function(match) {
+        return (match[0] == "<" ? match : "<span class=mention>" + match + "</span>")
+      }
+    )
+  },
+
+  // on initial display, fetch backlog
+  componentDidMount: function() {
+    Main.scrollTo = -1;
+    Chat.fetch_backlog()
+  },
+
+  // if we are at the bottom of the page, keep it that way
+  componentWillUpdate: function() {
+    if (window.pageYOffset + window.innerHeight >= document.documentElement.scrollHeight) {
+      Main.scrollTo = -1
+    } else {
+      Main.scrollTo = null
+    }
+  }
+});
+
+//
+// Secretary Roll Call update form
+var RollCall = React.createClass({
+  displayName: "RollCall",
+
+  getInitialState: function() {
+    this.state = {};
+    RollCall.lockFocus = false;
+    this.state.guest = "";
+    return this.state
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement(
+      "section",
+      {className: "flexbox"},
+
+      React.createElement(
+        "section",
+        {id: "rollcall"},
+        React.createElement("h3", null, "Directors"),
+
+        React.createElement.apply(React, function() {
+          var $_ = ["ul", null];
+
+          self.state.people.forEach(function(person) {
+            if (person.role == "director") {
+              $_.push(React.createElement(Attendee, {person: person}))
+            }
+          });
+
+          return $_
+        }()),
+
+        React.createElement("h3", null, "Executive Officers"),
+
+        React.createElement.apply(React, function() {
+          var $_ = ["ul", null];
+
+          self.state.people.forEach(function(person) {
+            if (person.role == "officer") {
+              $_.push(React.createElement(Attendee, {person: person}))
+            }
+          });
+
+          return $_
+        }()),
+
+        React.createElement("h3", null, "Guests"),
+
+        React.createElement.apply(React, function() {
+          var $_ = ["ul", null];
+
+          self.state.people.forEach(function(person) {
+            if (person.role == "guest") {
+              $_.push(React.createElement(Attendee, {person: person}))
+            }
+          });
+
+          // walk-on guest support
+          $_.push(React.createElement(
+            "li",
+            null,
+
+            React.createElement("input", {
+              className: "walkon",
+              value: self.state.guest,
+              disabled: self.state.disabled,
+
+              onFocus: function() {
+                RollCall.lockFocus = true
+              },
+
+              onBlur: function() {
+                RollCall.lockFocus = false
+              },
+
+              onChange: function(event) {
+                self.setState({guest: event.target.value})
+              }
+            })
+          ));
+
+          var guest, found;
+
+          if (self.state.guest.length >= 3) {
+            guest = self.state.guest.toLowerCase().split(" ");
+            found = false;
+
+            Server.committers.forEach(function(person) {
+              if (guest.every(function(part) {
+                return person.id.indexOf(part) != -1 || person.name.toLowerCase().indexOf(part) != -1
+              }) && !self.state.people.some(function(registered) {
+                return registered.id == person.id
+              })) {
+                $_.push(React.createElement(Attendee, {person: person, walkon: true}));
+                found = true
+              }
+            });
+
+            // non committer
+            if (!found) {
+              $_.push(React.createElement(
+                Attendee,
+                {person: {name: self.state.guest}, walkon: true}
+              ))
+            }
+          };
+
+          return $_
+        }())
+      ),
+
+      React.createElement.apply(React, function() {
+        var $_ = ["section", null];
+        var minutes = Minutes.get(self.props.item.title);
+
+        if (minutes) {
+          $_.push(React.createElement("h3", null, "Minutes"));
+          $_.push(React.createElement("pre", {className: "comment"}, minutes))
+        };
+
+        return $_
+      }())
+    )
+  },
+
+  componentWillMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  // collect a sorted list of people
+  componentWillReceiveProps: function($$props) {
+    var people = [];
+
+    // start with those listed in the agenda
+    for (var id in $$props.item.people) {
+      var person = $$props.item.people[id];
+      person.id = id;
+      people.push(person)
+    };
+
+    // add remaining attendees
+    var attendees = Minutes.attendees;
+
+    if (attendees) {
+      for (var name in attendees) {
+        var person;
+
+        if (!people.some(function(person) {
+          return person.name == name
+        })) {
+          person = attendees[name];
+          person.name = name;
+          person.role = "guest";
+          people.push(person)
+        }
+      }
+    };
+
+    // sort list
+    this.setState({people: people.sort(function(person1, person2) {
+      return (person1.sortName > person2.sortName ? 1 : -1)
+    })})
+  },
+
+  // clear guest
+  clear_guest: function() {
+    this.setState({guest: ""})
+  },
+
+  // client side initialization on first rendering
+  componentDidMount: function() {
+    var self = this;
+
+    if (Server.committers) {
+      this.setState({disabled: false})
+    } else {
+      this.setState({disabled: true});
+
+      retrieve("committers", "json", function(committers) {
+        Server.committers = committers || [];
+        self.setState({disabled: false})
+      })
+    };
+
+    // export clear method
+    RollCall.clear_guest = this.clear_guest
+  },
+
+  // scroll walkon input field towards the center of the screen
+  componentDidUpdate: function() {
+    var walkon, offset;
+
+    if (RollCall.lockFocus && this.state.guest.length >= 3) {
+      walkon = document.getElementsByClassName("walkon")[0];
+      offset = walkon.offsetTop + walkon.offsetHeight / 2 - window.innerHeight / 2;
+      jQuery("html, body").animate({scrollTop: offset}, "slow")
+    }
+  }
+});
+
+//
+// An individual attendee (Director, Executive Officer, or Guest)
+//
+var Attendee = React.createClass({
+  displayName: "Attendee",
+
+  getInitialState: function() {
+    return {base: ""}
+  },
+
+  componentWillMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  // whenever person changes, reflect current status
+  componentWillReceiveProps: function($$props) {
+    var status = Minutes.attendees[$$props.person.name];
+
+    if (status) {
+      this.setState({
+        checked: status.present,
+        notes: (status.notes ? status.notes.replace(" - ", "") : "")
+      })
+    } else {
+      this.setState({checked: "", notes: ""})
+    }
+  },
+
+  // render a checkbox, a hypertexted link of the attendee's name to the
+  // roster page for the committer, and notes in both editable and non-editable
+  // forms.  CSS controls which version of the notes is actually displayed.
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = ["li", {onMouseOver: self.focus}];
+
+      $_.push(React.createElement(
+        "input",
+        {type: "checkbox", checked: self.state.checked, onChange: self.click}
+      ));
+
+      var roster = "/roster/committer/";
+
+      if (self.props.person.id) {
+        $_.push(React.createElement(
+          "a",
+
+          {
+            href: roster + self.props.person.id,
+            style: {fontWeight: (self.props.person.member ? "bold" : "normal")}
+          },
+
+          self.props.person.name
+        ))
+      } else {
+        $_.push(React.createElement(
+          "a",
+          {className: "hilite", href: roster + "?q=" + self.props.person.name},
+          self.props.person.name
+        ))
+      };
+
+      if (!self.props.walkon && !self.state.checked && self.props.person.role != "guest" && !self.props.person.attending) {
+        if (!self.state.notes) {
+          $_.push(React.createElement("span", null, " (expected to be absent)"))
+        }
+      };
+
+      if (!self.props.walkon) {
+        $_.push(React.createElement("label"));
+
+        $_.push(React.createElement("input", {
+          type: "text",
+          value: self.state.notes,
+          onBlur: self.blur,
+          disabled: self.state.disabled,
+
+          onChange: function(event) {
+            self.setState({notes: event.target.value})
+          }
+        }));
+
+        if (self.state.notes) {
+          $_.push(React.createElement("span", null, " - " + self.state.notes))
+        }
+      };
+
+      return $_
+    }())
+  },
+
+  // when moving cursor over a list item, focus on the input field
+  focus: function(event) {
+    if (!RollCall.lockFocus) {
+      event.target.parentNode.querySelector("input[type=text]").focus()
+    }
+  },
+
+  // initialize pending update status
+  componentDidMount: function() {
+    this.pending = false
+  },
+
+  // when checkbox is clicked, set pending update status
+  click: function(event) {
+    this.setState({checked: event.target.checked});
+    this.pending = true
+  },
+
+  // when leaving a list item, set pending update status if value changed
+  blur: function() {
+    if (this.state.base != this.state.notes) {
+      this.pending = true;
+      this.setState({base: this.state.notes})
+    }
+  },
+
+  // after display is updated, send any pending updates to the server
+  componentDidUpdate: function() {
+    var self = this;
+    if (!this.pending) return;
+
+    var data = {
+      agenda: Agenda.file,
+      action: "attendance",
+      name: this.props.person.name,
+      id: this.props.person.id,
+      present: this.state.checked,
+      notes: this.state.notes
+    };
+
+    this.setState({disabled: true});
+
+    post("minute", data, function(minutes) {
+      Minutes.load(minutes);
+      if (self.props.walkon) RollCall.clear_guest();
+      self.setState({disabled: false})
+    });
+
+    this.pending = false
+  }
+});
+
+//
+// Action items.  Link to PMC reports when possible, highlight missing
+// action item status updates.
+//
+var SelectActions = React.createClass({
+  displayName: "SelectActions",
+  statics: {buttons: function() {return [{button: PostActions}]}},
+
+  getInitialState: function() {
+    this.state = {};
+    SelectActions.list = [];
+    this.state.names = [];
+    return this.state
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement(
+      "span",
+      null,
+      React.createElement("h3", null, "Post Action Items"),
+
+      React.createElement(
+        "p",
+        {className: "alert-info"},
+        "Action Items have yet to be posted. " + "Unselect the ones below that have been completed. " + "Click on the \"post actions\" button when done."
+      ),
+
+      React.createElement.apply(React, function() {
+        var $_ = ["pre", {className: "report"}];
+
+        SelectActions.list.forEach(function(action) {
+          $_.push(React.createElement(
+            CandidateAction,
+            {action: action, names: self.state.names}
+          ))
+        });
+
+        return $_
+      }())
+    )
+  },
+
+  componentDidMount: function() {
+    var self = this;
+
+    retrieve("potential-actions", "json", function(response) {
+      if (response) {
+        SelectActions.list = response.actions;
+        self.setState({names: response.names})
+      }
+    })
+  }
+});
+
+var CandidateAction = React.createClass({
+  displayName: "CandidateAction",
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = ["span", null];
+
+      $_.push(React.createElement("input", {
+        type: "checkbox",
+        checked: !self.props.action.complete,
+
+        onChange: function() {
+          self.props.action.complete = !self.props.action.complete;
+          self.forceUpdate()
+        }
+      }));
+
+      $_.push(React.createElement("span", null, " "));
+      $_.push(React.createElement("span", null, self.props.action.owner));
+      $_.push(React.createElement("span", null, ": "));
+      $_.push(React.createElement("span", null, self.props.action.text));
+
+      $_.push(React.createElement(
+        "span",
+        null,
+        "\n      [ " + self.props.action.pmc + " " + self.props.action.date + " ]\n      "
+      ));
+
+      if (self.props.action.status) {
+        $_.push(React.createElement(Text, {
+          raw: "Status: " + self.props.action.status + "\n",
+          filters: [hotlink]
+        }))
+      };
+
+      $_.push(React.createElement("span", null, "\n"));
+      return $_
+    }())
+  }
+});
+
+//
+// A page showing status of caches and service workers
+//
+var CacheStatus = React.createClass({
+  displayName: "CacheStatus",
+
+  statics: {buttons: function() {
+    return [{button: ClearCache}, {button: UnregisterWorker}]
+  }},
+
+  getInitialState: function() {
+    return {cache: [], registrations: []}
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = ["span", null];
+      $_.push(React.createElement("h2", null, "Status"));
+
+      if (typeof navigator !== 'undefined' && "serviceWorker" in navigator) {
+        $_.push(React.createElement(
+          "p",
+          null,
+          "Service workers ARE supported by this browser"
+        ))
+      } else {
+        $_.push(React.createElement(
+          "p",
+          null,
+          "Service workers are NOT supported by this browser"
+        ))
+      };
+
+      $_.push(React.createElement("h2", null, "Cache"));
+
+      if (self.state.cache.length == 0) {
+        $_.push(React.createElement("p", null, "empty"))
+      } else {
+        $_.push(React.createElement.apply(React, function() {
+          var $_ = ["ul", null];
+
+          self.state.cache.forEach(function(item) {
+            var basename = item.split("/").pop();
+            if (basename == "") basename = "index.html";
+
+            if (basename == "bootstrap.html") {
+              basename = item.split("/")[item.split("/").length - 2] + ".html"
+            };
+
+            $_.push(React.createElement(
+              "li",
+              null,
+              React.createElement(Link, {text: item, href: "cache/" + basename})
+            ))
+          });
+
+          return $_
+        }()))
+      };
+
+      $_.push(React.createElement("h2", null, "Service Workers"));
+
+      if (self.state.registrations.length == 0) {
+        $_.push(React.createElement("p", null, "none found"))
+      } else {
+        $_.push(React.createElement(
+          "table",
+          {className: "table"},
+
+          React.createElement(
+            "thead",
+            null,
+            React.createElement("th", null, "Scope"),
+            React.createElement("th", null, "Status")
+          ),
+
+          React.createElement.apply(React, function() {
+            var $_ = ["tbody", null];
+
+            self.state.registrations.forEach(function(registration) {
+              $_.push(React.createElement(
+                "tr",
+                null,
+                React.createElement("td", null, registration.scope),
+
+                React.createElement.apply(React, function() {
+                  var $_ = ["td", null];
+
+                  if (registration.installing) {
+                    $_.push(React.createElement("span", null, "installing"))
+                  } else if (registration.waiting) {
+                    $_.push(React.createElement("span", null, "waiting"))
+                  } else if (registration.active) {
+                    $_.push(React.createElement("span", null, "active"))
+                  } else {
+                    $_.push(React.createElement("span", null, "unknown"))
+                  };
+
+                  return $_
+                }())
+              ))
+            });
+
+            return $_
+          }())
+        ))
+      };
+
+      return $_
+    }())
+  },
+
+  componentDidMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  // update caches
+  componentWillReceiveProps: function($$props) {
+    var self = this;
+
+    if (typeof caches !== 'undefined') {
+      caches.open("board/agenda").then(function(cache) {
+        cache.matchAll().then(function(responses) {
+          cache = responses.map(function(response) {
+            return response.url
+          });
+
+          cache.sort();
+          self.setState({cache: cache})
+        })
+      });
+
+      navigator.serviceWorker.getRegistrations().then(function(registrations) {
+        self.setState({registrations: registrations})
+      })
+    }
+  }
+});
+
+//
+// A button that clear the cache
+//
+var ClearCache = React.createClass({
+  displayName: "ClearCache",
+
+  getInitialState: function() {
+    return {disabled: true}
+  },
+
+  render: function() {
+    return React.createElement(
+      "button",
+
+      {
+        className: "btn btn-primary",
+        onClick: this.click,
+        disabled: this.state.disabled
+      },
+
+      "Clear Cache"
+    )
+  },
+
+  componentDidMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  // enable button if there is anything in the cache
+  componentWillReceiveProps: function($$props) {
+    var self = this;
+
+    if (typeof caches !== 'undefined') {
+      caches.open("board/agenda").then(function(cache) {
+        cache.matchAll().then(function(responses) {
+          self.setState({disabled: responses.length == 0})
+        })
+      })
+    }
+  },
+
+  click: function(event) {
+    if (typeof caches !== 'undefined') {
+      caches.delete("board/agenda").then(function(status) {
+        Main.refresh()
+      })
+    }
+  }
+});
+
+//
+// A button that removes the service worker.  Sadly, it doesn't seem to have
+// any affect on the list of registrations that is dynamically returned.
+//
+var UnregisterWorker = React.createClass({
+  displayName: "UnregisterWorker",
+
+  render: function() {
+    return React.createElement(
+      "button",
+      {className: "btn btn-primary", onClick: this.click},
+      "Unregister ServiceWorker"
+    )
+  },
+
+  click: function(event) {
+    if (typeof caches !== 'undefined') {
+      navigator.serviceWorker.getRegistrations().then(function(registrations) {
+        var base = new URL("..", document.getElementsByTagName("base")[0].href).href;
+
+        registrations.forEach(function(registration) {
+          if (registration.scope == base) {
+            registration.unregister().then(function(status) {
+              Main.refresh()
+            })
+          }
+        })
+      })
+    }
+  }
+});
+
+//
+// Individual Cache page
+//
+var CachePage = React.createClass({
+  displayName: "CachePage",
+
+  getInitialState: function() {
+    return {response: {}, text: ""}
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = ["span", null];
+      $_.push(React.createElement("h2", null, self.state.response.url));
+
+      $_.push(React.createElement(
+        "p",
+        null,
+        self.state.response.status + " " + self.state.response.statusText
+      ));
+
+      var keys, iterator, entry;
+
+      if (self.state.response.headers) {
+        // avoid buggy @response.headers.keys()
+        keys = [];
+        iterator = self.state.response.headers.entries();
+        entry = iterator.next();
+
+        while (!entry.done) {
+          if (entry.value[0] != "status") keys.push(entry.value[0]);
+          entry = iterator.next()
+        };
+
+        keys.sort();
+
+        $_.push(React.createElement.apply(React, function() {
+          var $_ = ["ul", null];
+
+          keys.forEach(function(key) {
+            $_.push(React.createElement(
+              "li",
+              null,
+              key + ": " + self.state.response.headers.get(key)
+            ))
+          });
+
+          return $_
+        }()))
+      };
+
+      $_.push(React.createElement("pre", null, self.state.text));
+      return $_
+    }())
+  },
+
+  // update on first update
+  componentDidMount: function() {
+    var self = this;
+    var basename;
+
+    if (typeof caches !== 'undefined') {
+      basename = location.href.split("/").pop();
+      if (basename == "index.html") basename = "";
+      if (/^\d+-\d+-\d+\.html$/.test(basename)) basename = "bootstrap.html";
+
+      caches.open("board/agenda").then(function(cache) {
+        cache.matchAll().then(function(responses) {
+          responses.forEach(function(response) {
+            if (response.url.split("/").pop() == basename) {
+              self.setState({response: response});
+
+              response.text().then(function(text) {
+                self.setState({text: text})
+              })
+            }
+          })
+        })
+      })
+    }
+  }
+});
+
+//
+// FY22 budget worksheet
+//
+var FY22 = React.createClass({
+  displayName: "FY22",
+
+  getInitialState: function() {
+    this.state = {budget: (Minutes.started && Minutes.get("budget")) || {
+      donations: 110,
+      sponsorship: 1000,
+      infrastructure: 868,
+      publicity: 352,
+      brandManagement: 141,
+      conferences: 12,
+      travelAssistance: 79,
+      treasury: 51,
+      fundraising: 23,
+      generalAndAdministrative: 139
+    }};
+
+    if (Server.role == "secretary" || !Minutes.started) {
+      this.state.disabled = false
+    } else {
+      this.state.disabled = true
+    };
+
+    this.recalc();
+    return this.state
+  },
+
+  render: function() {
+    return React.createElement(
+      "span",
+      null,
+
+      React.createElement(
+        "style",
+        null,
+        "\n" + "      .table thead tr th {text-align: right}\n" + "      .table tbody tr td {text-align: left}\n" + "      .table tbody tr td.num {text-align: right}\n" + "      .table tbody tr td.indented {padding-left: 2em}\n" + "      .table tbody tr td input {align: right; text-align: right}\n" + "      .table tbody tr td a {color: blue; text-decoration:underline}\n" + "    "
+      ),
+
+      React.createElement(
+        "p",
+        null,
+        "Instructions: change any input field and press the tab key to see " + "new results. Try to make FY22 Budget Net non-negative."
+      ),
+
+      React.createElement(
+        "table",
+        {className: "table table-sm table-striped"},
+
+        React.createElement("thead", null, React.createElement(
+          "tr",
+          null,
+          React.createElement("th"),
+          React.createElement("th", null, "FY17"),
+          React.createElement("th", null, "Min FY22"),
+          React.createElement("th", null, "FY22"),
+          React.createElement("th", null, "Max FY22"),
+          React.createElement("th", null, "FY22 Budget")
+        )),
+
+        React.createElement(
+          "tbody",
+          null,
+
+          React.createElement(
+            "tr",
+            null,
+            React.createElement("td", {colSpan: 6}, "Income")
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+
+            React.createElement(
+              "td",
+              {className: "indented"},
+
+              React.createElement(
+                "a",
+                {href: "https://s.apache.org/sxYI"},
+                "Total Public Donations"
+              )
+            ),
+
+            React.createElement("td", {className: "num"}, 89),
+            React.createElement("td", {className: "num"}, 90),
+            React.createElement("td", {className: "num"}, 110),
+            React.createElement("td", {className: "num"}, 135),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+
+              React.createElement("input", {
+                id: "donations",
+                onBlur: this.change,
+                disabled: this.state.disabled,
+                defaultValue: this.state.budget.donations.toLocaleString()
+              })
+            )
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+
+            React.createElement(
+              "td",
+              {className: "indented"},
+
+              React.createElement(
+                "a",
+                {href: "https://s.apache.org/sxYI"},
+                "Total Sponsorship"
+              )
+            ),
+
+            React.createElement("td", {className: "num"}, 968),
+            React.createElement("td", {className: "num"}, 900),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+              (1000).toLocaleString()
+            ),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+              (1100).toLocaleString()
+            ),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+
+              React.createElement("input", {
+                id: "sponsorship",
+                onBlur: this.change,
+                disabled: this.state.disabled,
+                defaultValue: this.state.budget.sponsorship.toLocaleString()
+              })
+            )
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+            React.createElement("td", {className: "indented"}, "Total Programs"),
+            React.createElement("td", {className: "num"}, 28),
+            React.createElement("td", {className: "num"}, 28),
+            React.createElement("td", {className: "num"}, 28),
+            React.createElement("td", {className: "num"}, 28),
+            React.createElement("td", {className: "num"}, 28)
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+            React.createElement("td", {className: "indented"}, "Interest Income"),
+            React.createElement("td", {className: "num"}, 4),
+            React.createElement("td", {className: "num"}, 4),
+            React.createElement("td", {className: "num"}, 4),
+            React.createElement("td", {className: "num"}, 4),
+            React.createElement("td", {className: "num"}, 4)
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+            React.createElement("td"),
+            React.createElement("td", {className: "num"}, "----"),
+            React.createElement("td", {className: "num"}, "----"),
+            React.createElement("td", {className: "num"}, "----"),
+            React.createElement("td", {className: "num"}, "----"),
+            React.createElement("td", {className: "num"}, "----")
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+            React.createElement("td", {className: "indented"}, "Total Income"),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+              (1089).toLocaleString()
+            ),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+              (1022).toLocaleString()
+            ),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+              (1142).toLocaleString()
+            ),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+              (1267).toLocaleString()
+            ),
+
+            React.createElement(
+              "td",
+              {className: "num", id: "income"},
+              this.state.budget.income.toLocaleString()
+            )
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+            React.createElement("td", {colSpan: 6})
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+            React.createElement("td", {colSpan: 6}, "Expense")
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+
+            React.createElement(
+              "td",
+              {className: "indented"},
+
+              React.createElement(
+                "a",
+                {href: "https://s.apache.org/Rlse"},
+                "Infrastructure"
+              )
+            ),
+
+            React.createElement("td", {className: "num"}, 723),
+            React.createElement("td", {className: "num"}, 868),
+            React.createElement("td", {className: "num"}, 868),
+            React.createElement("td", {className: "num"}, 868),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+
+              React.createElement("input", {
+                id: "infrastructure",
+                onBlur: this.change,
+                disabled: this.state.disabled,
+                defaultValue: this.state.budget.infrastructure.toLocaleString()
+              })
+            )
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+
+            React.createElement(
+              "td",
+              {className: "indented"},
+              "Program Expenses"
+            ),
+
+            React.createElement("td", {className: "num"}, 27),
+            React.createElement("td", {className: "num"}, 27),
+            React.createElement("td", {className: "num"}, 27),
+            React.createElement("td", {className: "num"}, 27),
+            React.createElement("td", {className: "num"}, 27)
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+
+            React.createElement(
+              "td",
+              {className: "indented"},
+
+              React.createElement(
+                "a",
+                {href: "https://s.apache.org/lv76"},
+                "Publicity"
+              )
+            ),
+
+            React.createElement("td", {className: "num"}, 141),
+            React.createElement("td", {className: "num"}, 273),
+            React.createElement("td", {className: "num"}, 352),
+            React.createElement("td", {className: "num"}, 540),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+
+              React.createElement("input", {
+                id: "publicity",
+                onBlur: this.change,
+                disabled: this.state.disabled,
+                defaultValue: this.state.budget.publicity.toLocaleString()
+              })
+            )
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+
+            React.createElement(
+              "td",
+              {className: "indented"},
+
+              React.createElement(
+                "a",
+                {href: "https://s.apache.org/gXdY"},
+                "Brand Management"
+              )
+            ),
+
+            React.createElement("td", {className: "num"}, 84),
+            React.createElement("td", {className: "num"}, 92),
+            React.createElement("td", {className: "num"}, 141),
+            React.createElement("td", {className: "num"}, 218),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+
+              React.createElement("input", {
+                id: "brandManagement",
+                onBlur: this.change,
+                disabled: this.state.disabled,
+                defaultValue: this.state.budget.brandManagement.toLocaleString()
+              })
+            )
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+            React.createElement("td", {className: "indented"}, "Conferences"),
+            React.createElement("td", {className: "num"}, 12),
+            React.createElement("td", {className: "num"}, 12),
+            React.createElement("td", {className: "num"}, 12),
+            React.createElement("td", {className: "num"}, 12),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+
+              React.createElement("input", {
+                id: "conferences",
+                onBlur: this.change,
+                disabled: this.state.disabled,
+                defaultValue: this.state.budget.conferences.toLocaleString()
+              })
+            )
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+
+            React.createElement(
+              "td",
+              {className: "indented"},
+
+              React.createElement(
+                "a",
+                {href: "https://s.apache.org/4LdI"},
+                "Travel Assistance"
+              )
+            ),
+
+            React.createElement("td", {className: "num"}, 62),
+            React.createElement("td", {className: "num"}, 0),
+            React.createElement("td", {className: "num"}, 79),
+            React.createElement("td", {className: "num"}, 150),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+
+              React.createElement("input", {
+                id: "travelAssistance",
+                onBlur: this.change,
+                disabled: this.state.disabled,
+                defaultValue: this.state.budget.travelAssistance.toLocaleString()
+              })
+            )
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+
+            React.createElement(
+              "td",
+              {className: "indented"},
+
+              React.createElement(
+                "a",
+                {href: "https://s.apache.org/EGiC"},
+                "Treasury"
+              )
+            ),
+
+            React.createElement("td", {className: "num"}, 48),
+            React.createElement("td", {className: "num"}, 49),
+            React.createElement("td", {className: "num"}, 51),
+            React.createElement("td", {className: "num"}, 61),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+
+              React.createElement("input", {
+                id: "treasury",
+                onBlur: this.change,
+                disabled: this.state.disabled,
+                defaultValue: this.state.budget.treasury.toLocaleString()
+              })
+            )
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+
+            React.createElement(
+              "td",
+              {className: "indented"},
+
+              React.createElement(
+                "a",
+                {href: "https://s.apache.org/sxYI"},
+                "Fundraising"
+              )
+            ),
+
+            React.createElement("td", {className: "num"}, 8),
+            React.createElement("td", {className: "num"}, 18),
+            React.createElement("td", {className: "num"}, 23),
+            React.createElement("td", {className: "num"}, 23),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+
+              React.createElement("input", {
+                id: "fundraising",
+                onBlur: this.change,
+                disabled: this.state.disabled,
+                defaultValue: this.state.budget.fundraising.toLocaleString()
+              })
+            )
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+
+            React.createElement(
+              "td",
+              {className: "indented"},
+
+              React.createElement(
+                "a",
+                {href: "https://s.apache.org/4LdI"},
+                "General & Administrative"
+              )
+            ),
+
+            React.createElement("td", {className: "num"}, 114),
+            React.createElement("td", {className: "num"}, 50),
+            React.createElement("td", {className: "num"}, 139),
+            React.createElement("td", {className: "num"}, 300),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+
+              React.createElement("input", {
+                id: "generalAndAdministrative",
+                onBlur: this.change,
+                disabled: this.state.disabled,
+                defaultValue: this.state.budget.generalAndAdministrative.toLocaleString()
+              })
+            )
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+            React.createElement("td"),
+            React.createElement("td", {className: "num"}, "----"),
+            React.createElement("td", {className: "num"}, "----"),
+            React.createElement("td", {className: "num"}, "----"),
+            React.createElement("td", {className: "num"}, "----"),
+            React.createElement("td", {className: "num"}, "----")
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+            React.createElement("td", {className: "indented"}, "Total Expense"),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+              (1219).toLocaleString()
+            ),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+              (1390).toLocaleString()
+            ),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+              (1693).toLocaleString()
+            ),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+              (2199).toLocaleString()
+            ),
+
+            React.createElement(
+              "td",
+              {className: "num", id: "expense"},
+              this.state.budget.expense.toLocaleString()
+            )
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+            React.createElement("td", {colSpan: 6})
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+            React.createElement("td", null, "Net"),
+            React.createElement("td", {className: "num"}, -130),
+            React.createElement("td", {className: "num"}, -369),
+            React.createElement("td", {className: "num"}, -552),
+            React.createElement("td", {className: "num"}, -993),
+
+            React.createElement(
+              "td",
+
+              {
+                className: "num " + (this.state.budget.net < 0 ? "danger" : "success"),
+                id: "net"
+              },
+
+              this.state.budget.net.toLocaleString()
+            )
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+            React.createElement("td", {colSpan: 6})
+          ),
+
+          React.createElement(
+            "tr",
+            null,
+            React.createElement("td", null, "Cash"),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+              (1656).toLocaleString()
+            ),
+
+            React.createElement("td", {className: "num"}, 290),
+            React.createElement("td", {className: "num"}, -259),
+
+            React.createElement(
+              "td",
+              {className: "num"},
+              (-1403).toLocaleString()
+            ),
+
+            React.createElement(
+              "td",
+              {className: "num", id: "cash"},
+              this.state.budget.cash.toLocaleString()
+            )
+          )
+        )
+      ),
+
+      React.createElement(
+        "p",
+        null,
+        "Units are in thousands of dollars US."
+      )
+    )
+  },
+
+  // evaluate computed fields
+  recalc: function() {
+    this.state.budget.income = this.state.budget.donations + this.state.budget.sponsorship + 28 + 4;
+    this.state.budget.expense = this.state.budget.infrastructure + 27 + this.state.budget.publicity + this.state.budget.brandManagement + this.state.budget.conferences + this.state.budget.travelAssistance + this.state.budget.treasury + this.state.budget.fundraising + this.state.budget.generalAndAdministrative;
+    this.state.budget.net = this.state.budget.income - this.state.budget.expense;
+    this.state.budget.cash = 1656 - 2 * 130 + 3 * this.state.budget.net
+  },
+
+  // update budget item when an input field changes
+  change: function(event) {
+    var self = this;
+
+    this.state.budget[event.target.id] = parseInt(event.target.value.replace(
+      /\D/g,
+      ""
+    )) || 0;
+
+    event.target.value = this.state.budget[event.target.id].toLocaleString();
+    this.recalc();
+
+    if (Server.role == "secretary" && Minutes.started) {
+      post(
+        "budget",
+        {agenda: Agenda.file, budget: this.state.budget},
+
+        function(budget) {
+          if (budget) self.setState({budget: budget})
+        }
+      )
+    };
+
+    this.forceUpdate()
+  },
+
+  // receive updated budget values
+  componentWillReceiveProps: function($$props) {
+    var budget = Minutes.get("budget");
+
+    if (budget && budget != this.state.budget && Minutes.started) {
+      for (var item in budget) {
+        var element = document.getElementById(item);
+
+        if (element.tagName == "INPUT") {
+          element.value = budget[item].toLocaleString()
+        } else {
+          element.textContent = budget[item].toLocaleString()
+        }
+      };
+
+      this.setState({budget: budget});
+      if (Server.role != "secretary") this.setState({disabled: true})
+    }
+  }
+});
+
+//
+// This component handles both add and edit comment actions.  The save
+// button is disabled until the comment is changed.  A delete button is
+// provided to clear the comment if it isn't already empty.
+//
+// When the save button is pushed, a POST request is sent to the server.
+// When a response is received, the pending status is updated and the
+// form is dismissed.
+//
+var AddComment = React.createClass({
+  displayName: "AddComment",
+
+  statics: {button: {
+    text: "add comment",
+    class: "btn_primary",
+    data_toggle: "modal",
+    data_target: "#comment-form"
+  }},
+
+  getInitialState: function() {
+    return {
+      base: this.props.item.pending,
+      comment: this.props.item.pending,
+      disabled: false,
+      checked: this.props.item.flagged
+    }
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = [ModalDialog, {id: "comment-form", color: "commented"}];
+
+      // header
+      if (self.state.base) {
+        $_.push(React.createElement("h4", null, "Edit comment"))
+      } else {
+        $_.push(React.createElement("h4", null, "Enter a comment"))
+      };
+
+      //input field: initials
+      $_.push(React.createElement("input", {
+        id: "comment-initials",
+        label: "Initials",
+        placeholder: "initials",
+        disabled: self.state.disabled,
+        defaultValue: self.props.server.pending.initials || self.props.server.initials
+      }));
+
+      //input field: comment text
+      $_.push(React.createElement("textarea", {
+        id: "comment-text",
+        value: self.state.comment,
+        label: "Comment",
+        placeholder: "comment",
+        rows: 5,
+        onChange: self.change,
+        disabled: self.state.disabled
+      }));
+
+      if (Server.role == "director" && /^[A-Z]+$/.test(self.props.item.attach)) {
+        $_.push(React.createElement("input", {
+          id: "flag",
+          type: "checkbox",
+          label: "item requires discussion or follow up",
+          onChange: self.flag,
+          checked: self.state.checked
+        }))
+      };
+
+      // footer buttons
+      $_.push(React.createElement(
+        "button",
+
+        {
+          className: "btn-default",
+          "data-dismiss": "modal",
+          disabled: self.state.disabled
+        },
+
+        "Cancel"
+      ));
+
+      if (self.state.comment) {
+        $_.push(React.createElement(
+          "button",
+
+          {
+            className: "btn-warning",
+            onClick: self.delete,
+            disabled: self.state.disabled
+          },
+
+          "Delete"
+        ))
+      };
+
+      $_.push(React.createElement(
+        "button",
+
+        {
+          className: "btn-primary",
+          onClick: self.save,
+          disabled: self.state.disabled || self.state.comment == self.state.base
+        },
+
+        "Save"
+      ));
+
+      return $_
+    }())
+  },
+
+  // autofocus on comment text
+  componentDidMount: function() {
+    jQuery("#comment-form").on("shown.bs.modal", function() {
+      document.getElementById("comment-text").focus()
+    })
+  },
+
+  // update comment when textarea changes, triggering hiding/showing the
+  // Delete button and enabling/disabling the Save button.
+  change: function(event) {
+    this.setState({comment: event.target.value})
+  },
+
+  // when item changes, reset base and comment
+  componentWillReceiveProps: function(newprops) {
+    if (newprops.item.href != this.props.item.href) {
+      this.setState({
+        checked: newprops.item.flagged,
+        base: newprops.item.pending || "",
+        comment: newprops.item.pending || ""
+      })
+    }
+  },
+
+  // when delete button is pushed, clear the comment
+  delete: function(event) {
+    this.setState({comment: ""})
+  },
+
+  // when save button is pushed, post comment and dismiss modal when complete
+  save: function(event) {
+    var self = this;
+    Server.initials = document.getElementById("comment-initials").value;
+
+    var data = {
+      agenda: Agenda.file,
+      attach: this.props.item.attach,
+      initials: Server.initials,
+      comment: this.state.comment
+    };
+
+    this.setState({disabled: true});
+
+    post("comment", data, function(pending) {
+      jQuery("#comment-form").modal("hide");
+      document.body.classList.remove("modal-open");
+      self.setState({disabled: false});
+      Pending.load(pending)
+    })
+  },
+
+  flag: function(event) {
+    this.setState({checked: !this.state.checked});
+
+    var data = {
+      agenda: Agenda.file,
+      initials: Server.initials,
+      attach: this.props.item.attach,
+      request: (event.target.checked ? "flag" : "unflag")
+    };
+
+    post("approve", data, function(pending) {Pending.load(pending)})
+  }
+});
+
+var AddMinutes = React.createClass({
+  displayName: "AddMinutes",
+
+  statics: {button: {
+    text: "add minutes",
+    class: "btn_primary",
+    data_toggle: "modal",
+    data_target: "#minute-form"
+  }},
+
+  getInitialState: function() {
+    return {disabled: false}
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = [
+        ModalDialog,
+        {className: "wide-form", id: "minute-form", color: "commented"}
+      ];
+
+      $_.push(React.createElement(
+        "h4",
+        {className: "commented"},
+        "Minutes"
+      ));
+
+      // either a large text area, or a slightly smaller text area
+      // followed by comments
+      if (self.props.item.comments.length == 0) {
+        $_.push(React.createElement("textarea", {
+          className: "form-control",
+          id: "minute-text",
+          rows: 17,
+          tabIndex: 1,
+          placeholder: "minutes",
+          value: self.state.draft,
+
+          onChange: function(event) {
+            self.setState({draft: event.target.value})
+          }
+        }))
+      } else {
+        $_.push(React.createElement("textarea", {
+          className: "form-control",
+          id: "minute-text",
+          rows: 12,
+          tabIndex: 1,
+          placeholder: "minutes",
+          value: self.state.draft,
+
+          onChange: function(event) {
+            self.setState({draft: event.target.value})
+          }
+        }));
+
+        $_.push(React.createElement("h3", null, "Comments"));
+
+        $_.push(React.createElement.apply(React, function() {
+          var $_ = ["div", {id: "minute-comments"}];
+
+          self.props.item.comments.forEach(function(comment) {
+            $_.push(React.createElement("pre", {className: "comment"}, comment))
+          });
+
+          return $_
+        }()))
+      };
+
+      // action items
+      $_.push(React.createElement(
+        "div",
+        {className: "row", style: {marginTop: "1em"}},
+
+        React.createElement(
+          "button",
+
+          {
+            className: "btn btn-sm btn-info col-md-offset-1 col-md-1",
+            onClick: self.addAI,
+            disabled: !self.state.ai_owner || !self.state.ai_text
+          },
+
+          "+ AI"
+        ),
+
+        React.createElement(
+          "label",
+          {className: "col-md-2"},
+
+          React.createElement.apply(React, function() {
+            var $_ = [
+              "select",
+
+              {value: self.state.ai_owner, onChange: function(event) {
+                self.setState({ai_owner: event.target.value})
+              }}
+            ];
+
+            Minutes.attendee_names.forEach(function(name) {
+              $_.push(React.createElement("option", null, name))
+            });
+
+            return $_
+          }())
+        ),
+
+        React.createElement("textarea", {
+          className: "col-md-7",
+          value: self.state.ai_text,
+          rows: 1,
+          cols: 40,
+          tabIndex: 2,
+
+          onChange: function(event) {
+            self.setState({ai_text: event.target.value})
+          }
+        })
+      ));
+
+      // variable number of buttons
+      $_.push(React.createElement(
+        "button",
+
+        {
+          className: "btn-default",
+          type: "button",
+          "data-dismiss": "modal",
+
+          onClick: function() {
+            self.setState({draft: self.state.base})
+          }
+        },
+
+        "Cancel"
+      ));
+
+      if (self.state.base) {
+        $_.push(React.createElement(
+          "button",
+
+          {className: "btn-warning", type: "button", onClick: function() {
+            self.setState({draft: ""})
+          }},
+
+          "Delete"
+        ))
+      };
+
+      // special buttons for prior months draft minutes
+      if (/^3\w/.test(self.props.item.attach)) {
+        $_.push(React.createElement(
+          "button",
+
+          {
+            className: "btn-warning",
+            type: "button",
+            onClick: self.save,
+            disabled: self.state.disabled
+          },
+
+          "Tabled"
+        ));
+
+        $_.push(React.createElement(
+          "button",
+
+          {
+            className: "btn-success",
+            type: "button",
+            onClick: self.save,
+            disabled: self.state.disabled
+          },
+
+          "Approved"
+        ))
+      };
+
+      $_.push(React.createElement(
+        "button",
+        {className: self.reflow_color(), onClick: self.reflow},
+        "Reflow"
+      ));
+
+      $_.push(React.createElement(
+        "button",
+
+        {
+          className: "btn-primary",
+          type: "button",
+          onClick: self.save,
+          disabled: self.state.disabled || self.state.base == self.state.draft
+        },
+
+        "Save"
+      ));
+
+      return $_
+    }())
+  },
+
+  // autofocus on minute text
+  componentDidMount: function() {
+    jQuery("#minute-form").on("shown.bs.modal", function() {
+      document.getElementById("minute-text").focus()
+    })
+  },
+
+  // when initially displayed, set various fields to match the item
+  componentWillMount: function() {
+    this.setup(this.props.item)
+  },
+
+  // when item changes, reset various fields to match
+  componentWillReceiveProps: function(newprops) {
+    if (newprops.item.href != this.props.item.href) this.setup(newprops.item)
+  },
+
+  // reset base, draft minutes, shepherd, default ai_text, and indent
+  setup: function(item) {
+    this.setState({base: draft = Minutes.get(item.title) || ""});
+
+    if (/^(8|9|1\d)\.$/.test(item.attach)) {
+      draft = draft || item.text
+    } else if (!item.text) {
+      this.setState({ai_text: "pursue a report for " + item.title})
+    };
+
+    this.setState({
+      draft: draft,
+      ai_owner: item.shepherd,
+      indent: (/^\w+$/.test(this.props.item.attach) ? 8 : 4)
+    })
+  },
+
+  // add an additional AI to the draft minutes for this item
+  addAI: function(event) {
+    var $draft = this.state.draft;
+    if ($draft) $draft += "\n";
+    $draft += "@" + this.state.ai_owner + ": " + this.state.ai_text;
+
+    this.setState({
+      ai_owner: this.props.item.shepherd,
+      ai_text: "",
+      draft: $draft
+    })
+  },
+
+  // determine if reflow button should be default or danger color
+  reflow_color: function() {
+    var width = 78 - this.state.indent;
+
+    if (!this.state.draft || this.state.draft.split("\n").every(function(line) {
+      return line.length <= width
+    })) {
+      return "btn-default"
+    } else {
+      return "btn-danger"
+    }
+  },
+
+  reflow: function() {
+    console.log("reflowing");
+
+    console.log(Flow.text(
+      this.state.draft || "",
+      new Array(this.state.indent + 1).join(" ")
+    ));
+
+    this.setState({draft: Flow.text(
+      this.state.draft || "",
+      new Array(this.state.indent + 1).join(" ")
+    )})
+  },
+
+  save: function(event) {
+    var self = this;
+    var text;
+
+    switch (event.target.textContent) {
+    case "Save":
+      text = this.state.draft;
+      break;
+
+    case "Tabled":
+      text = "tabled";
+      break;
+
+    case "Approved":
+      text = "approved"
+    };
+
+    var data = {
+      agenda: Agenda.file,
+      title: this.props.item.title,
+      text: text
+    };
+
+    this.setState({disabled: true});
+
+    post("minute", data, function(minutes) {
+      Minutes.load(minutes);
+      self.setup(self.props.item);
+      self.setState({disabled: false});
+      jQuery("#minute-form").modal("hide");
+      document.body.classList.remove("modal-open")
+    })
+  }
+});
+
+//
+// Approve/Unapprove a report
+//
+var Approve = React.createClass({
+  displayName: "Approve",
+
+  getInitialState: function() {
+    return {disabled: false, request: "approve"}
+  },
+
+  // render a single button
+  render: function() {
+    return React.createElement(
+      "button",
+
+      {
+        className: "btn btn-primary",
+        onClick: this.click,
+        disabled: this.state.disabled
+      },
+
+      this.state.request
+    )
+  },
+
+  componentWillMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  // set request (and button text) depending on whether or not the
+  // not this items was previously approved
+  componentWillReceiveProps: function($$props) {
+    if (Pending.approved.indexOf($$props.item.attach) != -1) {
+      this.setState({request: "unapprove"})
+    } else if (Pending.unapproved.indexOf($$props.item.attach) != -1) {
+      this.setState({request: "approve"})
+    } else if ($$props.item.approved && $$props.item.approved.indexOf(Server.initials) != -1) {
+      this.setState({request: "unapprove"})
+    } else {
+      this.setState({request: "approve"})
+    }
+  },
+
+  // when button is clicked, send request
+  click: function(event) {
+    var self = this;
+
+    var data = {
+      agenda: Agenda.file,
+      initials: Server.initials,
+      attach: this.props.item.attach,
+      request: this.state.request
+    };
+
+    this.setState({disabled: true});
+
+    post("approve", data, function(pending) {
+      self.setState({disabled: false});
+      Pending.load(pending)
+    })
+  }
+});
+
+//
+// Indicate intention to attend / regrets for meeting
+//
+var Attend = React.createClass({
+  displayName: "Attend",
+
+  getInitialState: function() {
+    return {disabled: false}
+  },
+
+  render: function() {
+    return React.createElement(
+      "button",
+
+      {
+        className: "btn btn-primary",
+        onClick: this.click,
+        disabled: this.state.disabled
+      },
+
+      (this.state.attending ? "regrets" : "attend")
+    )
+  },
+
+  componentWillMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  // match person by either userid or name
+  componentWillReceiveProps: function($$props) {
+    var person = $$props.item.people[Server.userid];
+
+    if (person) {
+      this.setState({attending: person.attending})
+    } else {
+      this.setState({attending: false});
+
+      for (var id in $$props.item.people) {
+        person = $$props.item.people[id];
+
+        if (person.name == Server.username) {
+          this.setState({attending: person.attending})
+        }
+      }
+    }
+  },
+
+  click: function(event) {
+    var self = this;
+
+    var data = {
+      agenda: Agenda.file,
+      action: (this.state.attending ? "regrets" : "attend"),
+      name: Server.username,
+      userid: Server.userid
+    };
+
+    this.setState({disabled: true});
+
+    post("attend", data, function(response) {
+      self.setState({disabled: false});
+      Agenda.load(response.agenda, response.digest)
+    })
+  }
+});
+
+//
+// Commit pending comments and approvals.  Build a default commit message,
+// and allow it to be changed.
+//
+var Commit = React.createClass({
+  displayName: "Commit",
+
+  statics: {button: {
+    text: "commit",
+    class: "btn_primary",
+    data_toggle: "modal",
+    data_target: "#commit-form"
+  }},
+
+  getInitialState: function() {
+    return {disabled: false}
+  },
+
+  // commit form: allow the user to confirm or edit the commit message
+  render: function() {
+    var self = this;
+
+    return React.createElement(
+      ModalDialog,
+      {id: "commit-form", color: "blank"},
+      React.createElement("h4", null, "Commit message"),
+
+      React.createElement("textarea", {
+        id: "commit-text",
+        value: this.state.message,
+        rows: 5,
+        disabled: this.state.disabled,
+        label: "Commit message",
+
+        onChange: function(event) {
+          self.setState({message: event.target.value})
+        }
+      }),
+
+      React.createElement(
+        "button",
+        {className: "btn-default", "data-dismiss": "modal"},
+        "Close"
+      ),
+
+      React.createElement(
+        "button",
+
+        {
+          className: "btn-primary",
+          onClick: this.click,
+          disabled: this.state.disabled
+        },
+
+        "Submit"
+      )
+    )
+  },
+
+  componentWillMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  // autofocus on comment text
+  componentDidMount: function() {
+    jQuery("#commit-form").on("shown.bs.modal", function() {
+      document.getElementById("commit-text").focus()
+    })
+  },
+
+  // update message on re-display
+  componentWillReceiveProps: function($$props) {
+    var pending = $$props.server.pending;
+    var messages = [];
+
+    // common format for message lines
+    var append = function(title, list) {
+      if (!list) return;
+      var titles;
+
+      if (list.length > 0 && list.length < 6) {
+        titles = [];
+
+        Agenda.index.forEach(function(item) {
+          if (list.indexOf(item.attach) != -1) titles.push(item.title)
+        });
+
+        messages.push(title + " " + titles.join(", "))
+      } else if (list.length > 1) {
+        messages.push(title + " " + list.length + " reports")
+      }
+    };
+
+    append("Approve", pending.approved);
+    append("Unapprove", pending.unapproved);
+    append("Flag", pending.flagged);
+    append("Unflag", pending.unflagged);
+
+    // list (or number) of comments made with this commit
+    var comments = Object.keys(pending.comments).length;
+    var titles;
+
+    if (comments > 0 && comments < 6) {
+      titles = [];
+
+      Agenda.index.forEach(function(item) {
+        if (pending.comments[item.attach]) titles.push(item.title)
+      });
+
+      messages.push("Comment on " + titles.join(", "))
+    } else if (comments > 1) {
+      messages.push("Comment on " + comments + " reports")
+    };
+
+    // identify (or number) action item(s) updated with this commit
+    var item, text;
+
+    if (pending.status) {
+      if (pending.status.length == 1) {
+        item = pending.status[0];
+        text = item.text;
+
+        if (item.pmc || item.date) {
+          text += " [";
+          if (item.pmc) text += " " + item.pmc;
+          if (item.date) text += " " + item.date;
+          text += " ]"
+        };
+
+        messages.push("Update AI: " + text)
+      } else if (pending.status.length > 1) {
+        messages.push("Update " + pending.status.length + " action items")
+      }
+    };
+
+    this.setState({message: messages.join("\n")})
+  },
+
+  // update message when textarea changes
+  change: function(event) {
+    this.setState({message: event.target.value})
+  },
+
+  // on click, disable the input fields and buttons and submit
+  click: function(event) {
+    var self = this;
+    this.setState({disabled: true});
+
+    post(
+      "commit",
+      {message: this.state.message, initials: Pending.initials},
+
+      function(response) {
+        Agenda.load(response.agenda, response.digest);
+        Pending.load(response.pending);
+        self.setState({disabled: false});
+
+        // delay jQuery updates to give React a chance to make updates first
+        setTimeout(
+          function() {
+            jQuery("#commit-form").modal("hide");
+            document.body.classList.remove("modal-open");
+            jQuery(".modal-backdrop").remove()
+          },
+
+          300
+        )
+      }
+    )
+  }
+});
+
+var DraftMinutes = React.createClass({
+  displayName: "DraftMinutes",
+
+  statics: {button: {
+    text: "draft minutes",
+    class: "btn_danger",
+    data_toggle: "modal",
+    data_target: "#draft-minute-form"
+  }},
+
+  getInitialState: function() {
+    return {disabled: true}
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement(
+      ModalDialog,
+      {className: "wide-form", id: "draft-minute-form", color: "commented"},
+
+      React.createElement(
+        "h4",
+        {className: "commented"},
+        "Commit Draft Minutes to SVN"
+      ),
+
+      React.createElement("textarea", {
+        className: "form-control",
+        id: "draft-minute-text",
+        rows: 17,
+        tabIndex: 1,
+        placeholder: "minutes",
+        value: this.state.draft,
+        disabled: this.state.disabled,
+
+        onChange: function(event) {
+          self.setState({draft: event.target.value})
+        }
+      }),
+
+      React.createElement(
+        "button",
+        {className: "btn-default", type: "button", "data-dismiss": "modal"},
+        "Cancel"
+      ),
+
+      React.createElement(
+        "button",
+
+        {
+          className: "btn-primary",
+          type: "button",
+          onClick: this.save,
+          disabled: this.state.disabled
+        },
+
+        "Save"
+      )
+    )
+  },
+
+  // autofocus on minute text; fetch draft
+  componentDidMount: function() {
+    var self = this;
+    this.setState({draft: ""});
+
+    jQuery("#draft-minute-form").on("shown.bs.modal", function() {
+      retrieve(
+        "draft/" + Agenda.title.replace(/\-/g, "_"),
+        "text",
+
+        function(draft) {
+          document.getElementById("draft-minute-text").focus();
+          self.setState({disabled: false, draft: draft});
+          jQuery("#draft-minute-text").animate({scrollTop: 0})
+        }
+      )
+    })
+  },
+
+  save: function(event) {
+    var self = this;
+
+    var data = {
+      agenda: Agenda.file,
+      message: "Draft minutes for " + Agenda.title,
+      text: this.state.draft
+    };
+
+    this.setState({disabled: true});
+
+    post("draft", data, function() {
+      self.setState({disabled: false});
+      jQuery("#draft-minute-form").modal("hide");
+      document.body.classList.remove("modal-open")
+    })
+  }
+});
+
+//
+// A button that mark all comments as 'seen', with an undo option
+//
+var MarkSeen = React.createClass({
+  displayName: "MarkSeen",
+
+  getInitialState: function() {
+    this.state = {disabled: false, label: "mark seen"};
+    MarkSeen.undo = null;
+    return this.state
+  },
+
+  render: function() {
+    return React.createElement(
+      "button",
+
+      {
+        className: "btn btn-primary",
+        onClick: this.click,
+        disabled: this.state.disabled
+      },
+
+      this.state.label
+    )
+  },
+
+  click: function(event) {
+    var self = this;
+    this.setState({disabled: true});
+    var seen;
+
+    if (MarkSeen.undo) {
+      seen = MarkSeen.undo
+    } else {
+      seen = {};
+
+      Agenda.index.forEach(function(item) {
+        if (item.comments && item.comments.length != 0) {
+          seen[item.attach] = item.comments
+        }
+      })
+    };
+
+    post(
+      "markseen",
+      {seen: seen, agenda: Agenda.file},
+
+      function(pending) {
+        self.setState({disabled: false});
+
+        if (MarkSeen.undo) {
+          MarkSeen.undo = null;
+          self.setState({label: "mark seen"})
+        } else {
+          MarkSeen.undo = Pending.seen;
+          self.setState({label: "undo mark"})
+        };
+
+        Pending.load(pending)
+      }
+    )
+  }
+});
+
+//
+// Message area for backchannel
+//
+var Message = React.createClass({
+  displayName: "Message",
+
+  getInitialState: function() {
+    return {disabled: false, message: ""}
+  },
+
+  // render an input area in the button area (a very w-i-d-e button)
+  render: function() {
+    var self = this;
+
+    return React.createElement(
+      "form",
+      {onSubmit: this.sendMessage},
+
+      React.createElement("input", {
+        id: "chatMessage",
+        value: this.state.message,
+
+        onChange: function(event) {
+          self.setState({message: event.target.value})
+        }
+      })
+    )
+  },
+
+  // autofocus on the chat message when the page is initially displayed
+  componentDidMount: function() {
+    document.getElementById("chatMessage").focus()
+  },
+
+  // send message to server
+  sendMessage: function(event) {
+    var self = this;
+    event.stopPropagation();
+    event.preventDefault();
+
+    if (this.state.message) {
+      post(
+        "message",
+        {agenda: Agenda.file, text: this.state.message},
+
+        function(message) {
+          Chat.add(message);
+          self.setState({message: ""})
+        }
+      )
+    };
+
+    return false
+  }
+});
+
+//
+// Post or edit a report or resolution
+//
+// For new resolutions, allow entry of title, but not commit message
+// For everything else, allow modification of commit message, but not title
+var Post = React.createClass({
+  displayName: "Post",
+
+  statics: {button: {
+    text: "post report",
+    class: "btn_primary",
+    data_toggle: "modal",
+    data_target: "#post-report-form"
+  }},
+
+  getInitialState: function() {
+    return {disabled: false, alerted: false, edited: false}
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = [
+        ModalDialog,
+        {className: "wide-form", id: "post-report-form", color: "commented"}
+      ];
+
+      $_.push(React.createElement("h4", null, self.state.header));
+
+      //input field: title
+      if (self.props.button.text == "add resolution") {
+        $_.push(React.createElement("input", {
+          id: "post-report-title",
+          label: "title",
+          disabled: self.state.disabled,
+          placeholder: "title",
+          value: self.state.title,
+          onFocus: self.default_title,
+
+          onChange: function(event) {
+            self.setState({title: event.target.value})
+          }
+        }))
+      };
+
+      //input field: report text
+      $_.push(React.createElement("textarea", {
+        id: "post-report-text",
+        label: self.state.label,
+        value: self.state.report,
+        placeholder: self.state.label,
+        rows: 17,
+        disabled: self.state.disabled,
+        onChange: self.change_text
+      }));
+
+      // upload of spreadsheet from virtual
+      if (self.props.item.title == "Treasurer") {
+        $_.push(React.createElement("form", null, React.createElement(
+          "div",
+          {className: "form-group"},
+
+          React.createElement(
+            "label",
+            {htmlFor: "upload"},
+            "financial spreadsheet from virtual"
+          ),
+
+          React.createElement("input", {
+            id: "upload",
+            type: "file",
+            value: self.state.upload,
+
+            onChange: function(event) {
+              self.setState({upload: event.target.value})
+            }
+          }),
+
+          React.createElement(
+            "button",
+
+            {
+              className: "btn btn-primary",
+              onClick: self.upload,
+              disabled: self.state.disabled || !self.state.upload
+            },
+
+            "Upload"
+          )
+        )))
+      };
+
+      //input field: commit_message
+      if (self.props.button.text != "add resolution") {
+        $_.push(React.createElement("input", {
+          id: "post-report-message",
+          label: "commit message",
+          disabled: self.state.disabled,
+          value: self.state.message,
+
+          onChange: function(event) {
+            self.setState({message: event.target.value})
+          }
+        }))
+      };
+
+      $_.push(React.createElement(
+        "button",
+
+        {
+          className: "btn-default",
+          "data-dismiss": "modal",
+          disabled: self.state.disabled
+        },
+
+        "Cancel"
+      ));
+
+      $_.push(React.createElement(
+        "button",
+        {className: self.reflow_color(), onClick: self.reflow},
+        "Reflow"
+      ));
+
+      $_.push(React.createElement(
+        "button",
+
+        {
+          className: "btn-primary",
+          onClick: self.submit,
+          disabled: !self.ready()
+        },
+
+        "Submit"
+      ));
+
+      return $_
+    }())
+  },
+
+  componentWillMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  // autofocus on report/resolution title/text
+  componentDidMount: function() {
+    var self = this;
+
+    jQuery("#post-report-form").on("shown.bs.modal", function() {
+      if (self.props.button.text == "add resolution") {
+        document.getElementById("post-report-title").focus()
+      } else {
+        document.getElementById("post-report-text").focus()
+      }
+    })
+  },
+
+  // match form title, input label, and commit message with button text
+  componentWillReceiveProps: function(newprops) {
+    var $edited = this.state.edited;
+
+    switch (newprops.button.text) {
+    case "post report":
+
+      this.setState({
+        header: "Post Report",
+        label: "report",
+        message: "Post " + newprops.item.title + " Report"
+      });
+
+      break;
+
+    case "edit report":
+
+      this.setState({
+        header: "Edit Report",
+        label: "report",
+        message: "Edit " + newprops.item.title + " Report"
+      });
+
+      break;
+
+    case "add resolution":
+
+      this.setState({
+        header: "Add Resolution",
+        label: "resolution",
+        title: ""
+      });
+
+      break;
+
+    case "edit resolution":
+
+      this.setState({
+        header: "Edit Resolution",
+        label: "resolution",
+        message: "Edit " + newprops.item.title + " Resolution"
+      })
+    };
+
+    var text, $digest, $alerted;
+
+    if (!$edited || newprops.item.attach != this.props.item.attach) {
+      text = newprops.item.text || "";
+
+      if (newprops.item.title == "President") {
+        text = text.replace(
+          /\s*Additionally, please see Attachments \d through \d\./,
+          ""
+        )
+      };
+
+      this.setState({report: text});
+      $digest = newprops.item.digest;
+      $alerted = false;
+      $edited = false
+    } else if (!$alerted && $edited && $digest != newprops.item.digest) {
+      alert("edit conflict");
+      $alerted = true
+    };
+
+    if (newprops.button.text == "add resolution" || /^[47]/.test(newprops.item.attach)) {
+      this.setState({indent: "        "})
+    } else {
+      this.setState({indent: ""})
+    };
+
+    this.setState({edited: $edited, digest: $digest, alerted: $alerted})
+  },
+
+  // default title based on common resolution patterns
+  default_title: function(event) {
+    if (this.state.title) return;
+    var match = null;
+
+    if (match = this.state.report.match(/appointed\s+to\s+the\s+office\s+of\s+Vice\s+President,\s+Apache\s+(.*?),/)) {
+      this.setState({title: "Change the Apache " + match[1] + " Project Chair"})
+    } else if (match = this.state.report.match(/to\s+be\s+known\s+as\s+the\s+"Apache\s+(.*?)\s+Project",\s+be\s+and\s+hereby\s+is\s+established/)) {
+      this.setState({title: "Establish the Apache " + match[1] + " Project"})
+    } else if (match = this.state.report.match(/the\s+Apache\s+(.*?)\s+project\s+is\s+hereby\s+terminated/)) {
+      this.setState({title: "Terminate the Apache " + match[1] + " Project"})
+    }
+  },
+
+  // track changes to text value
+  change_text: function(event) {
+    this.setState({report: event.target.value, edited: true})
+  },
+
+  // determine if reflow button should be default or danger color
+  reflow_color: function() {
+    var width = 80 - this.state.indent.length;
+
+    if (this.state.report.split("\n").every(function(line) {
+      return line.length <= width
+    })) {
+      return "btn-default"
+    } else {
+      return "btn-danger"
+    }
+  },
+
+  // perform a reflow of report text
+  reflow: function() {
+    var report = this.state.report;
+
+    // remove indentation
+    var regex = /^( +)\S/gm;
+    var indents = [];
+
+    while (result = regex.exec(report)) {
+      indents.push(result[1].length)
+    };
+
+    if (indents.length != 0) {
+      report = report.replace(
+        new RegExp("^" + new Array(Math.min.apply(Math, indents) + 1).join(" "), "gm"),
+        ""
+      )
+    };
+
+    this.setState({report: Flow.text(report, this.state.indent)})
+  },
+
+  // determine if the form is ready to be submitted
+  ready: function() {
+    if (this.state.disabled) return false;
+
+    if (this.props.button.text == "add resolution") {
+      return this.state.report != "" && this.state.title != ""
+    } else {
+      return this.state.report != this.props.item.text && this.state.message != ""
+    }
+  },
+
+  // upload contents of spreadsheet in base64; append extracted table to report
+  upload: function(event) {
+    var self = this;
+    this.setState({disabled: true});
+    event.preventDefault();
+    var reader = new FileReader;
+
+    reader.onload = function(event) {
+      var result = event.target.result;
+
+      var base64 = btoa(String.fromCharCode.apply(
+        null,
+        new Uint8Array(result)
+      ));
+
+      post("financials", {spreadsheet: base64}, function(response) {
+        var report = self.state.report;
+        if (report && report.slice(-1) != "\n") report += "\n";
+        if (report) report += "\n";
+        report += response.table;
+        self.change_text({target: {value: report}});
+        self.setState({upload: null, disabled: false})
+      })
+    };
+
+    reader.readAsArrayBuffer(document.getElementById("upload").files[0])
+  },
+
+  // when save button is pushed, post comment and dismiss modal when complete
+  submit: function(event) {
+    var self = this;
+    this.setState({edited: false});
+    var data;
+
+    if (this.props.button.text == "add resolution") {
+      data = {
+        agenda: Agenda.file,
+        attach: "7?",
+        title: this.state.title,
+        report: this.state.report
+      }
+    } else {
+      data = {
+        agenda: Agenda.file,
+        attach: this.props.item.attach,
+        digest: this.state.digest,
+        message: this.state.message,
+        report: this.state.report
+      }
+    };
+
+    this.setState({disabled: true});
+
+    post("post", data, function(response) {
+      jQuery("#post-report-form").modal("hide");
+      document.body.classList.remove("modal-open");
+      self.setState({disabled: false});
+      Agenda.load(response.agenda, response.digest)
+    })
+  }
+});
+
+//
+// Indicate intention to attend / regrets for meeting
+//
+var PostActions = React.createClass({
+  displayName: "PostActions",
+
+  getInitialState: function() {
+    return {disabled: false}
+  },
+
+  render: function() {
+    return React.createElement(
+      "button",
+
+      {
+        className: "btn btn-primary",
+        onClick: this.click,
+        disabled: this.state.disabled || SelectActions.list.length == 0
+      },
+
+      "post actions"
+    )
+  },
+
+  click: function(event) {
+    var self = this;
+
+    var data = {
+      agenda: Agenda.file,
+      message: "Post Action Items",
+      actions: SelectActions.list
+    };
+
+    this.setState({disabled: true});
+
+    post("post-actions", data, function(response) {
+      self.setState({disabled: false});
+      Agenda.load(response.agenda, response.digest)
+    })
+  }
+});
+
+var PublishMinutes = React.createClass({
+  displayName: "PublishMinutes",
+
+  statics: {button: {
+    text: "publish minutes",
+    class: "btn_danger",
+    data_toggle: "modal",
+    data_target: "#publish-minutes-form"
+  }},
+
+  getInitialState: function() {
+    return {disabled: false, previous_title: null}
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement(
+      ModalDialog,
+
+      {
+        className: "wide-form",
+        id: "publish-minutes-form",
+        color: "commented"
+      },
+
+      React.createElement(
+        "h4",
+        {className: "commented"},
+        "Publish Minutes onto the ASF web site"
+      ),
+
+      React.createElement("textarea", {
+        className: "form-control",
+        id: "summary-text",
+        rows: 10,
+        tabIndex: 1,
+        value: this.state.summary,
+        disabled: this.state.disabled,
+        label: "Minutes summary",
+
+        onChange: function(event) {
+          self.setState({summary: event.target.value})
+        }
+      }),
+
+      React.createElement("input", {
+        id: "message",
+        label: "Commit message",
+        value: this.state.message,
+        disabled: this.state.disabled,
+
+        onChange: function(event) {
+          self.setState({message: event.target.value})
+        }
+      }),
+
+      React.createElement(
+        "button",
+        {className: "btn-default", type: "button", "data-dismiss": "modal"},
+        "Cancel"
+      ),
+
+      React.createElement(
+        "button",
+
+        {
+          className: "btn-primary",
+          type: "button",
+          onClick: this.publish,
+          disabled: this.state.disabled
+        },
+
+        "Submit"
+      )
+    )
+  },
+
+  componentWillMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  // when page title changes, update form values
+  componentWillReceiveProps: function($$props) {
+    var self = this;
+    var date, url;
+
+    if ($$props.item.title != this.state.previous_title) {
+      if (!$$props.item.attach) {
+        // Index page for a path month's agenda
+        this.summary(Agenda.index, Agenda.title.replace(/\-/g, "_"))
+      } else if (typeof XMLHttpRequest !== 'undefined') {
+        // Minutes from previous meetings section of the agenda
+        date = ($$props.item.text.match(/board_minutes_(\d+_\d+_\d+)\.txt/) || [])[1];
+
+        url = document.baseURI.replace(
+          new RegExp("[-\\d]+/$"),
+          date.replace(/_/g, "-")
+        ) + ".json";
+
+        retrieve(url, "json", function(agenda) {self.summary(agenda, date)})
+      };
+
+      this.setState({previous_title: $$props.item.title})
+    }
+  },
+
+  // autofocus on minute text
+  componentDidMount: function() {
+    jQuery("#publish-minutes-form").on("shown.bs.modal", function() {
+      document.getElementById("summary-text").focus()
+    })
+  },
+
+  // compute default summary for web site and commit message
+  summary: function(agenda, date) {
+    var summary = ("- [" + this.formatDate(date) + "]") + ("(../records/minutes/" + date.slice(
+      0,
+      4
+    ) + "/board_minutes_" + date + ".txt)\n");
+
+    agenda.forEach(function(item) {
+      if (/^7\w$/.test(item.attach)) {
+        if (item.minutes && item.minutes.toLowerCase().indexOf("tabled") != -1) {
+          summary += "    * " + item.title.trim() + " (tabled)\n"
+        } else {
+          summary += "    * " + item.title.trim() + "\n"
+        }
+      }
+    });
+
+    this.setState({
+      date: date,
+      summary: summary,
+      message: "Publish " + this.formatDate(date) + " minutes"
+    })
+  },
+
+  // convert date to displayable form
+  formatDate: function(date) {
+    var months = [
+      "January",
+      "February",
+      "March",
+      "April",
+      "May",
+      "June",
+      "July",
+      "August",
+      "September",
+      "October",
+      "November",
+      "December"
+    ];
+
+    date = new Date(date.replace(/_/g, "/"));
+    return date.getDate() + " " + months[date.getMonth()] + " " + (date.getYear() + 1900)
+  },
+
+  publish: function(event) {
+    var self = this;
+
+    var data = {
+      date: this.state.date,
+      summary: this.state.summary,
+      message: this.state.message
+    };
+
+    this.setState({disabled: true});
+
+    post("publish", data, function(drafts) {
+      self.setState({disabled: false});
+      Server.drafts = drafts;
+      jQuery("#publish-minutes-form").modal("hide");
+      document.body.classList.remove("modal-open");
+      window.open("https://cms.apache.org/www/publish", "_blank").focus()
+    })
+  }
+});
+
+//
+// Send initial and final reminders.  Note that this is a form (with an
+// associated button) as well as a second button.
+//
+var InitialReminder = React.createClass({
+  displayName: "InitialReminder",
+
+  statics: {button: {
+    text: "send initial reminders",
+    class: "btn_primary",
+    data_toggle: "modal",
+    data_target: "#reminder-form"
+  }},
+
+  getInitialState: function() {
+    return {disabled: true, subject: "", message: ""}
+  },
+
+  // fetch email template
+  loadText: function(event) {
+    var self = this;
+    var reminder;
+
+    if (event.target.textContent == "send initial reminders") {
+      reminder = "reminder1"
+    } else {
+      reminder = "reminder2"
+    };
+
+    retrieve(reminder, "json", function(response) {
+      self.setState({
+        subject: response.subject,
+        message: response.body,
+        disabled: false
+      })
+    })
+  },
+
+  // wire up event handlers
+  componentDidMount: function() {
+    var self = this;
+
+    Array.prototype.slice.call(document.querySelectorAll(".btn-primary")).forEach(function(button) {
+      if (button.getAttribute("data-target") == "#reminder-form") {
+        button.onclick = self.loadText
+      }
+    })
+  },
+
+  // commit form: allow the user to confirm or edit the commit message
+  render: function() {
+    var self = this;
+
+    return React.createElement(
+      ModalDialog,
+      {className: "wide-form", id: "reminder-form", color: "blank"},
+      React.createElement("h4", null, "Email message"),
+
+      React.createElement("input", {
+        id: "email-subject",
+        value: this.state.subject,
+        disabled: this.state.disabled,
+        label: "subject",
+        placeholder: "loading...",
+
+        onChange: function(event) {
+          self.setState({subject: event.target.value})
+        }
+      }),
+
+      React.createElement("textarea", {
+        id: "email-text",
+        value: this.state.message,
+        rows: 12,
+        disabled: this.state.disabled,
+        label: "body",
+        placeholder: "loading...",
+
+        onChange: function(event) {
+          self.setState({message: event.target.value})
+        }
+      }),
+
+      React.createElement(
+        "button",
+        {className: "btn-default", "data-dismiss": "modal"},
+        "Close"
+      ),
+
+      React.createElement(
+        "button",
+
+        {
+          className: "btn-info",
+          onClick: this.click,
+          disabled: this.state.disabled
+        },
+
+        "Dry Run"
+      ),
+
+      React.createElement(
+        "button",
+
+        {
+          className: "btn-primary",
+          onClick: this.click,
+          disabled: this.state.disabled
+        },
+
+        "Submit"
+      )
+    )
+  },
+
+  // on click, disable the input fields and buttons and submit
+  click: function(event) {
+    var self = this;
+    this.setState({disabled: true});
+    var dryrun = event.target.textContent == "Dry Run";
+
+    // data to be sent to the server
+    var data = {
+      dryrun: dryrun,
+      agenda: Agenda.file,
+      subject: this.state.subject,
+      message: this.state.message,
+      pmcs: []
+    };
+
+    // collect up a list of PMCs that are checked
+    Array.prototype.slice.call(document.querySelectorAll("input[type=checkbox]")).forEach(function(input) {
+      if (input.checked) data.pmcs.push(input.value)
+    });
+
+    post("send-reminders", data, function(response) {
+      if (!response) {
+        alert("Server error - check console log")
+      } else if (dryrun) {
+        console.log(response);
+        alert("Dry run - check console log")
+      } else if (response.count == data.pmcs.length) {
+        alert("Reminders have been sent to: " + data.pmcs.join(", ") + ".")
+      } else if (response.count && response.unsent) {
+        alert("Error: no emails were sent to " + response.unsent.join(", "))
+      } else {
+        alert("No reminders were sent")
+      };
+
+      self.setState({disabled: false});
+      jQuery("#reminder-form").modal("hide");
+      document.body.classList.remove("modal-open")
+    })
+  }
+});
+
+//
+// A button for final reminders
+//
+var FinalReminder = React.createClass({
+  displayName: "FinalReminder",
+
+  render: function() {
+    return React.createElement(
+      "button",
+
+      {
+        className: "btn btn-primary",
+        "data-toggle": "modal",
+        "data-target": "#reminder-form"
+      },
+
+      "send final reminders"
+    )
+  }
+});
+
+//
+// A button that will do a 'svn update' of the agenda on the server
+//
+var Refresh = React.createClass({
+  displayName: "Refresh",
+
+  getInitialState: function() {
+    return {disabled: false}
+  },
+
+  render: function() {
+    return React.createElement(
+      "button",
+
+      {
+        className: "btn btn-primary",
+        onClick: this.click,
+        disabled: this.state.disabled
+      },
+
+      "refresh"
+    )
+  },
+
+  click: function(event) {
+    var self = this;
+    this.setState({disabled: true});
+
+    post("refresh", {agenda: Agenda.file}, function(response) {
+      self.setState({disabled: false});
+      Agenda.load(response.agenda, response.digest)
+    })
+  }
+});
+
+//
+// Show/hide seen items
+//
+var ShowSeen = React.createClass({
+  displayName: "ShowSeen",
+
+  getInitialState: function() {
+    return {label: "show seen"}
+  },
+
+  render: function() {
+    return React.createElement(
+      "button",
+      {className: "btn btn-primary", onClick: this.click},
+      this.state.label
+    )
+  },
+
+  componentWillReceiveProps: function($$props) {
+    if (Main.view && !Main.view.showseen()) {
+      this.setState({label: "hide seen"})
+    } else {
+      this.setState({label: "show seen"})
+    }
+  },
+
+  click: function(event) {
+    Main.view.toggleseen();
+    this.componentWillReceiveProps(this.props)
+  }
+});
+
+//
+// Timestamp start/stop of meeting
+//
+var Timestamp = React.createClass({
+  displayName: "Timestamp",
+
+  getInitialState: function() {
+    return {disabled: false}
+  },
+
+  render: function() {
+    return React.createElement(
+      "button",
+
+      {
+        className: "btn btn-primary",
+        onClick: this.click,
+        disabled: this.state.disabled
+      },
+
+      "timestamp"
+    )
+  },
+
+  click: function(event) {
+    var self = this;
+
+    var data = {
+      agenda: Agenda.file,
+      title: this.props.item.title,
+      action: "timestamp"
+    };
+
+    this.setState({disabled: true});
+
+    post("minute", data, function(minutes) {
+      self.setState({disabled: false});
+      Minutes.load(minutes)
+    })
+  }
+});
+
+var Vote = React.createClass({
+  displayName: "Vote",
+
+  statics: {button: {
+    text: "vote",
+    class: "btn_primary",
+    data_toggle: "modal",
+    data_target: "#vote-form"
+  }},
+
+  getInitialState: function() {
+    return {disabled: false}
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = [
+        ModalDialog,
+        {className: "wide-form", id: "vote-form", color: "commented"}
+      ];
+
+      $_.push(React.createElement("h4", {className: "commented"}, "Vote"));
+
+      $_.push(React.createElement(
+        "p",
+        null,
+
+        React.createElement(
+          "span",
+          null,
+          self.state.votetype + " vote on the matter of "
+        ),
+
+        React.createElement(
+          "em",
+          null,
+          self.props.item.fulltitle.replace(/^Resolution to/, "")
+        )
+      ));
+
+      $_.push(React.createElement("pre", null, self.state.directors));
+
+      $_.push(React.createElement("textarea", {
+        id: "vote-text",
+        rows: 4,
+        placeholder: "minutes",
+        value: self.state.draft,
+
+        onChange: function(event) {
+          self.setState({draft: event.target.value})
+        }
+      }));
+
+      $_.push(React.createElement(
+        "button",
+
+        {
+          className: "btn-default",
+          type: "button",
+          "data-dismiss": "modal",
+
+          onClick: function() {
+            self.setState({draft: self.state.base})
+          }
+        },
+
+        "Cancel"
+      ));
+
+      if (self.state.base) {
+        $_.push(React.createElement(
+          "button",
+          {className: "btn-warning", type: "button", onClick: self.save},
+          "Delete"
+        ))
+      };
+
+      $_.push(React.createElement(
+        "button",
+
+        {
+          className: "btn-primary",
+          type: "button",
+          onClick: self.save,
+          disabled: self.state.draft == self.state.base
+        },
+
+        "Save"
+      ));
+
+      $_.push(React.createElement(
+        "button",
+
+        {
+          className: "btn-warning",
+          type: "button",
+          onClick: self.save,
+          disabled: self.state.draft != ""
+        },
+
+        "Tabled"
+      ));
+
+      $_.push(React.createElement(
+        "button",
+
+        {
+          className: "btn-success",
+          type: "button",
+          onClick: self.save,
+          disabled: self.state.draft != ""
+        },
+
+        "Unanimous"
+      ));
+
+      return $_
+    }())
+  },
+
+  componentWillMount: function() {
+    this.setup(this.props.item)
+  },
+
+  componentWillReceiveProps: function(newprops) {
+    if (newprops.item.href != this.props.item.href) this.setup(newprops.item)
+  },
+
+  // reset base, draft minutes, directors present, and vote type
+  setup: function(item) {
+    var $directors = Minutes.directors_present;
+
+    // alternate forward/reverse roll calls based on month and attachment
+    var month = new Date(Date.parse(Agenda.date)).getMonth();
+    var attach = item.attach.charCodeAt(1);
+
+    if ((month + attach) % 2 == 0) {
+      this.setState({votetype: "Roll call"})
+    } else {
+      this.setState({votetype: "Reverse roll call"});
+      $directors = $directors.split("\n").reverse().join("\n")
+    };
+
+    this.setState({
+      base: Minutes.get(item.title) || "",
+      draft: Minutes.get(item.title) || "",
+      directors: $directors
+    })
+  },
+
+  // post vote results
+  save: function(event) {
+    var self = this;
+    var text;
+
+    switch (event.target.textContent) {
+    case "Save":
+      text = this.state.draft;
+      break;
+
+    case "Delete":
+      text = "";
+      break;
+
+    case "Tabled":
+      text = "tabled";
+      break;
+
+    case "Unanimous":
+      text = "unanimous"
+    };
+
+    var data = {
+      agenda: Agenda.file,
+      title: this.props.item.title,
+      text: text
+    };
+
+    this.setState({disabled: true});
+
+    post("minute", data, function(minutes) {
+      Minutes.load(minutes);
+      self.setup(self.props.item);
+      self.setState({disabled: false});
+      jQuery("#vote-form").modal("hide");
+      document.body.classList.remove("modal-open")
+    })
+  }
+});
+
+//
+// Send email
+//
+var Email = React.createClass({
+  displayName: "Email",
+
+  render: function() {
+    return React.createElement(
+      "button",
+
+      {
+        className: "btn " + (this.mailto_class() || ""),
+        onClick: this.launch_email_client
+      },
+
+      "send email"
+    )
+  },
+
+  // render 'send email' as a primary button if the viewer is the shepherd for
+  // the report, otherwise render the text as a simple link.
+  mailto_class: function() {
+    if (Server.firstname && this.props.item.shepherd && Server.firstname.substring(
+      0,
+      this.props.item.shepherd.toLowerCase().length
+    ) == this.props.item.shepherd.toLowerCase()) {
+      return "btn-primary"
+    } else {
+      return "btn-link"
+    }
+  },
+
+  // launch email client, pre-filling the destination, subject, and body
+  launch_email_client: function() {
+    var destination = ("mailto:" + this.props.item.chair_email) + ("?cc=private@" + this.props.item.mail_list + ".apache.org,board@apache.org");
+    var subject, body;
+
+    if (this.props.item.missing) {
+      subject = "Missing " + this.props.item.title + " Board Report";
+      body = ("Dear " + this.props.item.owner + ",\n\nThe board report for ") + (this.props.item.title + " has not yet been submitted for this ") + "month's board meeting. If you're unable to get " + "it in by twenty-four hours before meeting time, " + "please plan to report next month.\n\nThanks,\n\n " + (Server.username + "\n\n") + "(on behalf of the ASF Board)"
+    } else {
+      subject = this.props.item.title + " Board Report";
+      body = this.props.item.comments
+    };
+
+    window.location = destination + ("&subject=" + encodeURIComponent(subject)) + ("&body=" + encodeURIComponent(body))
+  }
+});
+
+//
+// Display information associated with an agenda item:
+//   - special notes
+//   - minutes
+//   - posted reports
+//   - action items
+//   - posted comments
+//   - pending comments
+//   - historical comments
+//
+// Note: if AdditionalInfo is included multiple times in a page, set
+//       prefix to true (or a string) to ensure rendered id attributes
+//       are unique.
+//
+var AdditionalInfo = React.createClass({
+  displayName: "AdditionalInfo",
+
+  getInitialState: function() {
+    return {}
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = ["span", null];
+
+      // special notes
+      if (self.props.item.notes) {
+        $_.push(React.createElement(
+          "p",
+          {className: "notes"},
+          self.props.item.notes
+        ))
+      };
+
+      // minutes
+      var minutes = Minutes.get(self.props.item.title);
+
+      if (minutes) {
+        $_.push(React.createElement(
+          "h4",
+          {id: self.state.prefix + "minutes"},
+          "Minutes"
+        ));
+
+        $_.push(React.createElement("pre", {className: "comment"}, minutes))
+      };
+
+      // posted reports
+      var posted;
+
+      if (self.props.item.missing) {
+        posted = Posted.get(self.props.item.title);
+
+        if (posted.length != 0) {
+          $_.push(React.createElement(
+            "h4",
+            {id: self.state.prefix + "posted"},
+            "Posted reports"
+          ));
+
+          $_.push(React.createElement.apply(React, function() {
+            var $_ = ["ul", null];
+
+            posted.forEach(function(post) {
+              $_.push(React.createElement(
+                "li",
+                null,
+                React.createElement("a", {href: post.link}, post.subject)
+              ))
+            });
+
+            return $_
+          }()))
+        }
+      };
+
+      // action items
+      if (self.props.item.title != "Action Items" && self.props.item.actions.length != 0) {
+        $_.push(React.createElement(
+          "h4",
+          {id: self.state.prefix + "actions"},
+
+          React.createElement(
+            Link,
+            {text: "Action Items", href: "Action-Items"}
+          )
+        ));
+
+        $_.push(React.createElement(
+          ActionItems,
+          {item: self.props.item, filter: {pmc: self.props.item.title}}
+        ))
+      };
+
+      if (self.props.item.special_orders.length != 0) {
+        $_.push(React.createElement(
+          "h4",
+          {id: self.state.prefix + "orders"},
+          "Special Orders"
+        ));
+
+        $_.push(React.createElement.apply(React, function() {
+          var $_ = ["ul", null];
+
+          self.props.item.special_orders.forEach(function(resolution) {
+            $_.push(React.createElement("li", null, React.createElement(
+              Link,
+              {text: resolution.title, href: resolution.href}
+            )))
+          });
+
+          return $_
+        }()))
+      };
+
+      // posted comments
+      var history = HistoricalComments.find(self.props.item.title);
+
+      if (self.props.item.comments.length != 0 || (history && !self.state.prefix)) {
+        $_.push(React.createElement(
+          "h4",
+          {id: self.state.prefix + "comments"},
+          "Comments"
+        ));
+
+        self.props.item.comments.forEach(function(comment) {
+          $_.push(React.createElement(
+            "pre",
+            {className: "comment"},
+            React.createElement(Text, {raw: comment, filters: [hotlink]})
+          ))
+        });
+
+        // pending comments
+        if (self.props.item.pending) {
+          $_.push(React.createElement(
+            "h5",
+            {id: self.state.prefix + "pending"},
+            "Pending Comment"
+          ));
+
+          $_.push(React.createElement(
+            "pre",
+            {className: "comment"},
+            Flow.comment(self.props.item.pending, Pending.initials)
+          ))
+        };
+
+        // historical comments
+        if (history && !self.state.prefix) {
+          for (var date in history) {
+            if (Agenda.file == ("board_agenda_" + date + ".txt")) continue;
+
+            $_.push(React.createElement.apply(React, function() {
+              var $_ = ["h5", {className: "history"}];
+              $_.push(React.createElement("span", null, "• "));
+
+              $_.push(React.createElement(
+                "a",
+                {href: HistoricalComments.link(date, self.props.item.title)},
+                date.replace(/_/g, "-")
+              ));
+
+              $_.push(React.createElement("span", null, ": "));
+
+              // link to mail archive for feedback thread
+              var dfr, dto;
+
+              if (date > "2016_04") {
+                // compute date range: from date of that meeting to now
+                dfr = date.replace(/_/g, "-");
+                dto = new Date(Date.now()).toISOString().slice(0, 10);
+
+                $_.push(React.createElement(
+                  "a",
+                  {href: "https://lists.apache.org/list.html?board@apache.org&" + ("d=dfr=" + dfr + "|dto=" + dto + "&header_subject=") + ("'Board%20feedback%20on%20" + dfr + "%20" + self.props.item.title + "%20report'")},
+                  "(thread)"
+                ))
+              };
+
+              return $_
+            }()));
+
+            splitComments(history[date]).forEach(function(comment) {
+              $_.push(React.createElement(
+                "pre",
+                {className: "comment"},
+                React.createElement(Text, {raw: comment, filters: [hotlink]})
+              ))
+            })
+          }
+        }
+      } else if (self.props.item.pending) {
+        $_.push(React.createElement(
+          "h4",
+          {id: self.state.prefix + "pending"},
+          "Pending Comment"
+        ));
+
+        $_.push(React.createElement(
+          "pre",
+          {className: "comment"},
+          Flow.comment(self.props.item.pending, Pending.initials)
+        ))
+      };
+
+      return $_
+    }())
+  },
+
+  componentWillMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  // determine prefix (if any)
+  componentWillReceiveProps: function($$props) {
+    if ($$props.prefix == true) {
+      this.setState({prefix: $$props.item.title.toLowerCase() + "-"})
+    } else if ($$props.prefix) {
+      this.setState({prefix: $$props.prefix})
+    } else {
+      this.setState({prefix: ""})
+    }
+  }
+});
+
+//
+// Replacement for 'a' element which handles clicks events that can be
+// processed locally by calling Main.navigate.
+//
+var Link = React.createClass({
+  displayName: "Link",
+
+  getInitialState: function() {
+    return {attrs: {}}
+  },
+
+  componentWillMount: function() {
+    this.componentWillReceiveProps(this.props);
+    this.state.attrs.onClick = this.click
+  },
+
+  componentWillReceiveProps: function(props) {
+    this.setState({text: props.text});
+
+    for (var attr in props) {
+      if (!props[attr]) continue;
+      if (attr != "text") this.state.attrs[attr] = props[attr]
+    };
+
+    if (props.href) {
+      this.setState({element: "a"});
+
+      this.state.attrs.href = props.href.replace(
+        new RegExp("(^|/)\\w+/\\.\\.(/|$)", "g"),
+        "$1"
+      )
+    } else {
+      this.setState({element: "span"})
+    }
+  },
+
+  render: function() {
+    return React.createElement(
+      this.state.element,
+      this.state.attrs,
+      this.state.text
+    )
+  },
+
+  click: function(event) {
+    if (event.ctrlKey || event.shiftKey || event.metaKey) return;
+    var href = event.target.getAttribute("href");
+
+    if (new RegExp("^(\\.|cache/.*|(flagged/|(shepherd/)?(queue/)?)[-\\w]+)$").test(href)) {
+      event.stopPropagation();
+      event.preventDefault();
+      Main.navigate(href);
+      return false
+    }
+  }
+});
+
+//
+// Bootstrap modal dialogs are great, but they require a lot of boilerplate.
+// This component provides the boiler plate so that other form components
+// don't have to.  The elements provided by the calling component are
+// distributed to header, body, and footer sections.
+//
+var ModalDialog = React.createClass({
+  displayName: "ModalDialog",
+
+  getInitialState: function() {
+    return {header: [], body: [], footer: []}
+  },
+
+  componentWillMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  componentWillReceiveProps: function($$props) {
+    var self = this;
+    this.state.header.length = 0;
+    this.state.body.length = 0;
+    this.state.footer.length = 0;
+
+    $$props.children.forEach(function(child) {
+      var label, props;
+
+      if (child.type == "h4") {
+        // place h4 elements into the header, adding a modal-title class
+        child = self.addClass(child, "modal-title");
+        self.state.header.push(child);
+        ModalDialog.h4 = child
+      } else if (child.type == "button") {
+        // place button elements into the footer, adding a btn class
+        child = self.addClass(child, "btn");
+        self.state.footer.push(child)
+      } else if (child.type == "input" || child.type == "textarea") {
+        // wrap input and textarea elements in a form-control, 
+        // add label if present
+        child = self.addClass(child, "form-control");
+        label = null;
+
+        if (child.props.label && child.props.id) {
+          props = {htmlFor: child.props.id};
+
+          if (child.props.type == "checkbox") {
+            props.className = "checkbox";
+            label = React.createElement("label", props, child, child.props.label);
+            delete child.props.label;
+            child = null
+          } else {
+            label = React.createElement("label", props, child.props.label);
+            child = React.cloneElement(child, {label: null})
+          }
+        };
+
+        self.state.body.push(React.createElement(
+          "div",
+          {className: "form-group"},
+          label,
+          child
+        ))
+      } else {
+        // place all other elements into the body
+        self.state.body.push(child)
+      }
+    })
+  },
+
+  render: function() {
+    return React.createElement(
+      "div",
+
+      {
+        className: "modal fade " + (this.props.className || ""),
+        id: this.props.id
+      },
+
+      React.createElement(
+        "div",
+        {className: "modal-dialog"},
+
+        React.createElement(
+          "div",
+          {className: "modal-content"},
+
+          React.createElement.apply(React, [
+            "div",
+            {className: "modal-header " + (this.props.color || "")},
+
+            React.createElement(
+              "button",
+              {className: "close", type: "button", "data-dismiss": "modal"},
+              "×"
+            )
+          ].concat(this.state.header)),
+
+          React.createElement.apply(
+            React,
+            ["div", {className: "modal-body"}].concat(this.state.body)
+          ),
+
+          React.createElement.apply(
+            React,
+            ["div", {className: "modal-footer " + (this.props.color || "")}].concat(this.state.footer)
+          )
+        )
+      )
+    )
+  },
+
+  // helper method: add a class to an element, returning new element
+  addClass: function(element, name) {
+    if (!element.props.className) {
+      element = React.cloneElement(element, {className: name})
+    } else if (element.props.className.split(" ").indexOf(name) == -1) {
+      element = React.cloneElement(
+        element,
+        {className: element.props.className + (" " + name)}
+      )
+    };
+
+    return element
+  }
+});
+
+//
+// Escape text for inclusion in HTML; optionally apply filters
+//
+var Text = React.createClass({
+  displayName: "Text",
+
+  getInitialState: function() {
+    return {}
+  },
+
+  componentWillMount: function() {
+    this.componentWillReceiveProps(this.props)
+  },
+
+  componentWillReceiveProps: function($$props) {
+    var self = this;
+    var $text = htmlEscape($$props.raw || "");
+
+    if ($$props.filters) {
+      $$props.filters.forEach(function(filter) {
+        self.setState({text: $text = filter($text)})
+      })
+    };
+
+    this.setState({text: $text})
+  },
+
+  render: function() {
+    return React.createElement(
+      "span",
+      {dangerouslySetInnerHTML: {__html: this.state.text}}
+    )
+  }
+});
+
+var Info = React.createClass({
+  displayName: "Info",
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = [
+        "dl",
+        {className: "dl-horizontal " + (self.props.position || "")}
+      ];
+
+      $_.push(React.createElement("dt", null, "Attach"));
+      $_.push(React.createElement("dd", null, self.props.item.attach));
+
+      if (self.props.item.owner) {
+        $_.push(React.createElement("dt", null, "Author"));
+        $_.push(React.createElement("dd", null, self.props.item.owner))
+      };
+
+      if (self.props.item.shepherd) {
+        $_.push(React.createElement("dt", null, "Shepherd"));
+
+        $_.push(React.createElement.apply(React, function() {
+          var $_ = ["dd", null];
+
+          if (self.props.item.shepherd) {
+            $_.push(React.createElement(Link, {
+              text: self.props.item.shepherd,
+              href: "shepherd/" + self.props.item.shepherd.split(" ")[0]
+            }))
+          };
+
+          return $_
+        }()))
+      };
+
+      if (self.props.item.flagged_by && self.props.item.flagged_by.length != 0) {
+        $_.push(React.createElement("dt", null, "Flagged By"));
+
+        $_.push(React.createElement(
+          "dd",
+          null,
+          self.props.item.flagged_by.join(", ")
+        ))
+      };
+
+      if (self.props.item.approved && self.props.item.approved.length != 0) {
+        $_.push(React.createElement("dt", null, "Approved By"));
+
+        $_.push(React.createElement(
+          "dd",
+          null,
+          self.props.item.approved.join(", ")
+        ))
+      };
+
+      if (self.props.item.roster || self.props.item.prior_reports || self.props.item.stats) {
+        $_.push(React.createElement("dt", null, "Links"));
+
+        if (self.props.item.roster) {
+          $_.push(React.createElement(
+            "dd",
+            null,
+            React.createElement("a", {href: self.props.item.roster}, "Roster")
+          ))
+        };
+
+        if (self.props.item.prior_reports) {
+          $_.push(React.createElement("dd", null, React.createElement(
+            "a",
+            {href: self.props.item.prior_reports},
+            "Prior Reports"
+          )))
+        };
+
+        if (self.props.item.stats) {
+          $_.push(React.createElement(
+            "dd",
+            null,
+            React.createElement("a", {href: self.props.item.stats}, "Statistics")
+          ))
+        }
+      };
+
+      return $_
+    }())
+  }
+});
+
+//
+// Determine status of podling name
+//
+var PodlingNameSearch = React.createClass({
+  displayName: "PodlingNameSearch",
+
+  getInitialState: function() {
+    return {}
+  },
+
+  render: function() {
+    var self = this;
+
+    return React.createElement.apply(React, function() {
+      var $_ = ["span", {className: "pns", title: "podling name search"}];
+
+      if (Server.podlingnamesearch) {
+        if (!self.state.results) {
+          $_.push(React.createElement(
+            "abbr",
+            {title: "No PODLINGNAMESEARCH found"},
+            "✘"
+          ))
+        } else if (self.state.results.resolution == "Fixed") {
+          $_.push(React.createElement(
+            "a",
+            {href: "https://issues.apache.org/jira/browse/" + self.state.results.issue},
+            "✔"
+          ))
+        } else {
+          $_.push(React.createElement(
+            "a",
+            {href: "https://issues.apache.org/jira/browse/" + self.state.results.issue},
+            "ï¹–"
+          ))
+        }
+      };
+
+      return $_
+    }())
+  },
+
+  // initial mount: fetch podlingnamesearch data unless already downloaded
+  componentDidMount: function() {
+    var self = this;
+
+    if (Server.podlingnamesearch) {
+      this.check(this.props)
+    } else {
+      retrieve("podlingnamesearch", "json", function(results) {
+        Server.podlingnamesearch = results;
+        self.check(self.props)
+      })
+    }
+  },
+
+  // when properties (in particular: title) changes, lookup name again
+  componentWillReceiveProps: function(newprops) {
+    this.check(newprops)
+  },
+
+  // lookup name in the establish resolution against the podlingnamesearches
+  check: function(props) {
+    this.setState({results: null});
+    var name = (props.item.title.match(/Establish (.*)/) || [])[1];
+
+    // if full title contains a name in parenthesis, check for that name too
+    var altname = (props.item.fulltitle.match(/\((.*?)\)/) || [])[1];
+
+    if (name && Server.podlingnamesearch) {
+      for (var podling in Server.podlingnamesearch) {
+        if (name == podling) {
+          this.setState({results: Server.podlingnamesearch[name]})
+        } else if (altname == podling) {
+          this.setState({results: Server.podlingnamesearch[altname]})
+        }
+      }
+    }
+  }
+});
+
+//
+// Motivation: browsers limit the number of open web socket connections to any
+// one host to somewhere between 6 and 250, making it impractical to have one
+// Web Socket per tab.
+//
+// The solution below uses localStorage to communicate between tabs, with
+// the majority of logic involved with the "election" of a master.  This
+// enables a single open connection to service all tabs open by a browser.
+//
+// Alternatives include: 
+//
+// * Replacing localStorage with Service Workers.  This would be much cleaner,
+//   unfortunately Service Workers aren't widely deployed yet.  Sadly, the
+//   state isn't much better for Shared Web Workers.
+//
+//##
+//
+// Class variables:
+// * prefix:    application prefix for localStorage variables (which are
+//              shared across the domain).
+// * timestamp: unique identifier for each window/tab 
+// * master:    identifier of the current master
+// * ondeck:    identifier of the next in line to assume the role of master
+//
+function Events() {};
+Events._subscriptions = {};
+Events._socket = null;
+
+Events.subscribe = function(event, block) {
+  Events._subscriptions[event] = Events._subscriptions[event] || [];
+  Events._subscriptions[event].push(block)
+};
+
+Events.monitor = function() {
+  var self = this;
+  Events._prefix = JSONStorage.prefix;
+
+  // pick something unique to identify this tab/window
+  Events._timestamp = new Date().getTime() + Math.random();
+  this.log("Events id: " + Events._timestamp);
+
+  // determine the current master (if any)
+  Events._master = localStorage.getItem(Events._prefix + "-master");
+  this.log("Events.master: " + Events._master);
+
+  // register as a potential candidate for master
+  localStorage.setItem(
+    Events._prefix + "-ondeck",
+    Events._ondeck = Events._timestamp
+  );
+
+  // relinquish roles on exit
+  window.addEventListener("unload", function(event) {
+    if (Events._master == Events._timestamp) {
+      localStorage.removeItem(Events._prefix + "-master")
+    };
+
+    if (Events._ondeck == Events._timestamp) {
+      localStorage.removeItem(Events._prefix + "-ondeck")
+    }
+  });
+
+  // watch for changes
+  window.addEventListener("storage", function(event) {
+    // update tracking variables
+    if (event.key == (Events._prefix + "-master")) {
+      Events._master = event.newValue;
+      self.log("Events.master: " + Events._master);
+      self.negotiate()
+    } else if (event.key == (Events._prefix + "-ondeck")) {
+      Events._ondeck = event.newValue;
+      self.log("Events.ondeck: " + Events._ondeck);
+      self.negotiate()
+    } else if (event.key == (Events._prefix + "-event")) {
+      self.dispatch(event.newValue)
+    }
+  });
+
+  // dead man's switch: remove master when timestamp isn't updated
+  if (Events._master && Events._timestamp - localStorage.getItem(Events._prefix + "-timestamp") > 30000) {
+    this.log("Events: Removing previous master");
+    Events._master = localStorage.removeItem(Events._prefix + "-master")
+  };
+
+  // negotiate for the role of master
+  this.negotiate()
+};
+
+// negotiate changes in masters
+Events.negotiate = function() {
+  var self = this;
+  var options, request;
+
+  if (Events._master == null && Events._ondeck == Events._timestamp) {
+    this.log("Events: Assuming the role of master");
+
+    localStorage.setItem(
+      Events._prefix + "-timestamp",
+      new Date().getTime()
+    );
+
+    localStorage.setItem(
+      Events._prefix + "-master",
+      Events._master = Events._timestamp
+    );
+
+    Events._ondeck = localStorage.removeItem(Events._prefix + "-ondeck");
+
+    if (Server.session) {
+      this.master()
+    } else {
+      options = {credentials: "include"};
+      request = new Request("../session.json", options);
+
+      fetch(request).then(function(response) {
+        response.json().then(function(json) {
+          Server.session = json.session;
+          self.master()
+        })
+      })
+    }
+  } else if (Events._ondeck == null && Events._master != Events._timestamp && !localStorage.getItem(Events._prefix + "-ondeck")) {
+    localStorage.setItem(
+      Events._prefix + "-ondeck",
+      Events._ondeck = Events._timestamp
+    )
+  }
+};
+
+// master logic
+Events.master = function() {
+  var self = this;
+  this.connectToServer();
+
+  // proof of life; maintain connection to the server
+  setInterval(
+    function() {
+      localStorage.setItem(
+        Events._prefix + "-timestamp",
+        new Date().getTime()
+      );
+
+      self.connectToServer()
+    },
+
+    25000
+  );
+
+  // close connection on exit
+  window.addEventListener("unload", function(event) {
+    if (Events._socket) Events._socket.close()
+  })
+};
+
+// establish a connection to the server
+Events.connectToServer = function() {
+  var self = this;
+
+  try {
+    if (Events._socket) return;
+    var socket_url = window.location.protocol.replace("http", "ws") + "//" + window.location.hostname + ":34234/";
+    Events._socket = new WebSocket(Server.websocket);
+
+    Events._socket.onopen = function(event) {
+      Events._socket.send("session: " + Server.session + "\n\n");
+      self.log("WebSocket connection established")
+    };
+
+    Events._socket.onmessage = function(event) {
+      localStorage.setItem(Events._prefix + "-event", event.data);
+      self.dispatch(event.data)
+    };
+
+    Events._socket.onerror = function(event) {
+      if (Events._socket) self.log("WebSocket connection terminated");
+      Events._socket = null
+    };
+
+    Events._socket.onclose = function(event) {
+      if (Events._socket) self.log("WebSocket connection terminated");
+      Events._socket = null
+    }
+  } catch (e) {
+    this.log(e)
+  }
+};
+
+// dispatch logic (common to all tabs)
+Events.dispatch = function(data) {
+  var self = this;
+  var message = JSON.parse(data);
+  this.log(message);
+  var options, request;
+
+  if (message.type == "unauthorized") {
+    options = {credentials: "include"};
+    request = new Request("../session.json", options);
+
+    fetch(request).then(function(response) {
+      response.json().then(function(json) {
+        self.log(json);
+        Server.session = json.session
+      })
+    })
+  } else if (Events._subscriptions[message.type]) {
+    Events._subscriptions[message.type].forEach(function(sub) {
+      sub(message)
+    })
+  };
+
+  Main.refresh()
+};
+
+// log messages (unless running tests)
+Events.log = function(message) {
+  if (!navigator.userAgent || navigator.userAgent.indexOf("PhantomJS") != -1) {
+    return
+  };
+
+  console.log(message)
+};
+
+Object.defineProperty(
+  Events,
+  "prefix",
+
+  {enumerable: true, configurable: true, get: function() {
+    if (Events._prefix) return Events._prefix;
+
+    // determine localStorage variable prefix based on url up to the date
+    var base = document.getElementsByTagName("base")[0].href;
+    var origin = location.origin;
+
+    if (!origin) {
+      origin = window.location.protocol + "//" + window.location.hostname + ((window.location.port ? ":" + window.location.port : ""))
+    };
+
+    Events._prefix = base.slice(origin.length, base.length).replace(
+      new RegExp("/\\d{4}-\\d\\d-\\d\\d/.*"),
+      ""
+    ).replace(/^\W+|\W+$/g, "").replace(/\W+/g, "_") || location.port
+  }}
+);
+
+//
+// A cache of agenda related pages, useful for:
+//
+//  1) quick loading of possibly stale data, which will be updated with
+//     current information as it becomes available.
+//
+//  2) offline access to the agenda tool
+//
+function PageCache() {};
+
+// is page cache available?
+Object.defineProperty(
+  PageCache,
+  "enabled",
+
+  {enumerable: true, configurable: true, get: function() {
+    if (location.protocol != "https:" && location.hostname != "localhost") {
+      return false
+    };
+
+    // disable service workers for the production server(s) for now.  See:
+    // https://lists.w3.org/Archives/Public/public-webapps/2016JulSep/0016.html
+    if (/^whimsy.*\.apache\.org$/.test(location.hostname)) {
+      if (location.hostname.indexOf("-test") == -1) return false
+    };
+
+    return typeof ServiceWorker !== 'undefined' && typeof navigator !== 'undefined'
+  }}
+);
+
+// registration and related startup actions
+PageCache.register = function() {
+  // preload page cache once page finishes loading
+  window.addEventListener("load", function(event) {
+    PageCache.preload()
+  });
+
+  // register service worker
+  var scope = new URL("..", document.getElementsByTagName("base")[0].href);
+  navigator.serviceWorker.register(scope + "sw.js", scope)
+};
+
+// aggressively attempt to preload pages directly used by the agenda pages
+// into the appropriate cache.
+PageCache.preload = function() {
+  if (!PageCache.enabled) return;
+  var request = new Request("bootstrap.html", {credentials: "include"});
+
+  fetch(request).then(function(response) {
+    // add/update bootstrap.html in the cache
+    caches.open("board/agenda").then(function(cache) {
+      cache.put(request, response.clone())
+    })
+  })
+};
+
+//
+// This is the client model for an entire Agenda.  Class methods refer to
+// the agenda as a whole.  Instance methods refer to an individual agenda
+// item.
+//
+// initialize an entry by copying each JSON property to a class instance
+// variable.
+function Agenda(entry) {
+  for (var name in entry) {
+    this["_" + name] = entry[name]
+  }
+};
+
+Agenda._index = [];
+Agenda._etag = null;
+Agenda._digest = null;
+
+// (re)-load an agenda, creating instances for each item, and linking
+// each instance to their next and previous items.
+Agenda.load = function(list, digest) {
+  if (!list) return;
+  Agenda._digest = digest;
+  Agenda._index.length = 0;
+  var prev = null;
+
+  list.forEach(function(item) {
+    item = new Agenda(item);
+    item.prev = prev;
+    if (prev) prev.next = item;
+    prev = item;
+    Agenda._index.push(item)
+  });
+
+  // remove president attachments from the normal flow
+  Agenda._index.forEach(function(pres) {
+    match = pres.title == "President" && pres.text && pres.text.match(/Additionally, please see Attachments (\d) through (\d)/);
+    if (!match) return;
+    var first;
+    var last;
+    first = last = null;
+
+    Agenda._index.forEach(function(item) {
+      if (item.attach == match[1]) first = item;
+      if (first && !last) item._shepherd = item._shepherd || pres.shepherd;
+      if (item.attach == match[2]) last = item
+    });
+
+    if (first && last) {
+      first.prev.next = last.next;
+      last.next.prev = first.prev;
+      last.next.index = first.index;
+      first.index = null;
+      last.next = pres;
+      first.prev = pres
+    }
+  });
+
+  Agenda._date = (new Date(Agenda._index[0].timestamp).toISOString().match(/(.*?)T/) || [])[1];
+  Main.refresh();
+  return Agenda._index
+};
+
+// fetch agenda if etag is not supplied
+Agenda.fetch = function(etag, digest) {
+  var loaded, options, request, xhr;
+
+  if (etag) {
+    Agenda._etag = etag
+  } else if (digest != Agenda._digest || !Agenda._etag) {
+    if (PageCache.enabled) {
+      loaded = false;
+
+      // if bootstrapping and cache is available, load it
+      if (!digest) {
+        caches.open("board/agenda").then(function(cache) {
+          cache.match("../" + Agenda._date + ".json").then(function(response) {
+            if (response) {
+              response.json().then(function(json) {
+                if (!loaded) Agenda.load(json);
+                Main.refresh()
+              })
+            }
+          })
+        })
+      };
+
+      // set fetch options: credentials and etag
+      options = {credentials: "include"};
+      if (Agenda._etag) options["headers"] = {"If-None-Match": Agenda._etag};
+      request = new Request("../" + Agenda._date + ".json", options);
+
+      // perform fetch
+      fetch(request).then(function(response) {
+        if (response) {
+          loaded = true;
+
+          // load response into the agenda
+          response.clone().json().then(function(json) {
+            Agenda._etag = response.headers.get("etag");
+            Agenda.load(json);
+            Main.refresh()
+          });
+
+          // save response in the cache
+          caches.open("board/agenda").then(function(cache) {
+            cache.put(request, response)
+          })
+        }
+      })
+    } else {
+      // AJAX fallback
+      xhr = new XMLHttpRequest();
+      xhr.open("GET", "../" + Agenda._date + ".json", true);
+      if (Agenda._etag) xhr.setRequestHeader("If-None-Match", Agenda._etag);
+      xhr.responseType = "text";
+
+      xhr.onreadystatechange = function() {
+        if (xhr.readyState == 4 && xhr.status == 200 && xhr.responseText != "") {
+          Agenda._etag = xhr.getResponseHeader("ETag");
+          Agenda.load(JSON.parse(xhr.responseText));
+          Main.refresh()
+        }
+      };
+
+      xhr.send()
+    }
+  };
+
+  Agenda._digest = digest
+};
+
+// return the entire agenda
+Object.defineProperty(
+  Agenda,
+  "index",
+
+  {enumerable: true, configurable: true, get: function() {
+    return Agenda._index
+  }}
+);
+
+// find an agenda item by path name
+Agenda.find = function(path) {
+  var result = null;
+
+  Agenda._index.forEach(function(item) {
+    if (item.href == path) result = item
+  });
+
+  return result
+};
+
+Agenda.prototype = {
+  // provide read-only access to a number of properties 
+  get attach() {
+    return this._attach
+  },
+
+  get title() {
+    return this._title
+  },
+
+  get owner() {
+    return this._owner
+  },
+
+  get shepherd() {
+    return this._shepherd
+  },
+
+  get index() {
+    return this._index
+  },
+
+  get timestamp() {
+    return this._timestamp
+  },
+
+  get digest() {
+    return this._digest
+  },
+
+  get approved() {
+    return this._approved
+  },
+
+  get roster() {
+    return this._roster
+  },
+
+  get prior_reports() {
+    return this._prior_reports
+  },
+
+  get stats() {
+    return this._stats
+  },
+
+  get people() {
+    return this._people
+  },
+
+  get notes() {
+    return this._notes
+  },
+
+  get chair_email() {
+    return this._chair_email
+  },
+
+  get mail_list() {
+    return this._mail_list
+  },
+
+  get warnings() {
+    return this._warnings
+  },
+
+  get flagged_by() {
+    return this._flagged_by
+  },
+
+  get fulltitle() {
+    return this._fulltitle || this._title
+  },
+
+  // override missing if minutes aren't present
+  get missing() {
+    if (this._missing) {
+      return true
+    } else if (/^3\w$/.test(this._attach)) {
+      if (Server.drafts.indexOf((this._text.match(/board_minutes_\w+.txt/) || [])[0]) != -1) {
+        return false
+      } else {
+        return true
+      }
+    } else {
+      return false
+    }
+  },
+
+  // compute href by taking the title and replacing all non alphanumeric
+  // characters with dashes
+  get href() {
+    return this._title.replace(/[^a-zA-Z0-9]+/g, "-")
+  },
+
+  // return the text or report for the agenda item
+  get text() {
+    return this._text || this._report
+  },
+
+  // return comments as an array of individual comments
+  get comments() {
+    return splitComments(this._comments)
+  },
+
+  // item's comments excluding comments that have been seen before
+  get unseen_comments() {
+    var visible = [];
+    var seen = Pending.seen[this._attach] || [];
+
+    this.comments.forEach(function(comment) {
+      if (seen.indexOf(comment) == -1) visible.push(comment)
+    });
+
+    return visible
+  },
+
+  // retrieve the pending comment (if any) associated with this agenda item
+  get pending() {
+    return Pending.comments[this._attach]
+  },
+
+  // retrieve the action items associated with this agenda item
+  get actions() {
+    var self = this;
+    var item, list;
+
+    if (this._title == "Action Items") {
+      return this._actions
+    } else {
+      item = Agenda.find("Action-Items");
+      list = [];
+
+      if (item) {
+        item.actions.forEach(function(action) {
+          if (action.pmc == self._title) list.push(action)
+        })
+      };
+
+      return list
+    }
+  },
+
+  get special_orders() {
+    var self = this;
+    var items = [];
+
+    if (/^[A-Z]+$/.test(this._attach)) {
+      Agenda.index.forEach(function(item) {
+        if (/^7/.test(item.attach) && item.roster == self._roster) items.push(item)
+      })
+    };
+
+    return items
+  },
+
+  ready_for_review: function(initials) {
+    return typeof this._approved !== 'undefined' && !this.missing && this._approved.indexOf(initials) == -1 && !(this._flagged_by && this._flagged_by.indexOf(initials) != -1)
+  }
+};
+
+Object.defineProperties(Agenda, {
+  // the default view to use for the agenda as a whole
+  view: {enumerable: true, configurable: true, get: function() {
+    return Index
+  }},
+
+  // buttons to show on the index page
+  buttons: {enumerable: true, configurable: true, get: function() {
+    var list = [{button: Refresh}];
+    if (!Minutes.complete) list.push({form: Post, text: "add resolution"});
+
+    if (Server.role == "secretary") {
+      if (Server.drafts.indexOf(Agenda.file.replace("agenda", "minutes")) != -1) {
+        list.push({form: PublishMinutes})
+      } else if (Minutes.ready_to_post_draft) {
+        list.push({form: DraftMinutes})
+      }
+    };
+
+    return list
+  }},
+
+  // the default banner color to use for the agenda as a whole
+  color: {enumerable: true, configurable: true, get: function() {
+    return "blank"
+  }},
+
+  // the default title for the agenda as a whole
+  date: {enumerable: true, configurable: true, get: function() {
+    return Agenda._date
+  }},
+
+  title: {enumerable: true, configurable: true, get: function() {
+    return Agenda._date
+  }},
+
+  // the file associated with this agenda
+  file: {enumerable: true, configurable: true, get: function() {
+    return "board_agenda_" + Agenda._date.replace(/\-/g, "_") + ".txt"
+  }},
+
+  // get the digest of the file associated with this agenda
+  digest: {enumerable: true, configurable: true, get: function() {
+    return Agenda._digest
+  }},
+
+  // previous link for the agenda index page
+  prev: {enumerable: true, configurable: true, get: function() {
+    var result = {title: "Help", href: "help"};
+
+    Server.agendas.forEach(function(agenda) {
+      var date = (agenda.match(/(\d+_\d+_\d+)/) || [])[1].replace(
+        /_/g,
+        "-"
+      );
+
+      if (date < Agenda._date && (result.title == "Help" || date > result.title)) {
+        result = {title: date, href: "../" + date + "/"}
+      }
+    });
+
+    return result
+  }},
+
+  // next link for the agenda index page
+  next: {enumerable: true, configurable: true, get: function() {
+    var result = {title: "Help", href: "help"};
+
+    Server.agendas.forEach(function(agenda) {
+      var date = (agenda.match(/(\d+_\d+_\d+)/) || [])[1].replace(
+        /_/g,
+        "-"
+      );
+
+      if (date > Agenda._date && (result.title == "Help" || date < result.title)) {
+        result = {title: date, href: "../" + date + "/"}
+      }
+    });
+
+    return result
+  }},
+
+  // find the shortest match for shepherd name (example: Rich)
+  shepherd: {enumerable: true, configurable: true, get: function() {
+    var shepherd = null;
+    var firstname = Server.firstname.toLowerCase();
+
+    Agenda.index.forEach(function(item) {
+      if (item.shepherd && firstname.substring(
+        0,
+        item.shepherd.toLowerCase().length
+      ) == item.shepherd.toLowerCase() && (!shepherd || item.shepherd.length < shepherd.lenth)) {
+        shepherd = item.shepherd
+      }
+    });
+
+    return shepherd
+  }},
+
+  // summary
+  summary: {enumerable: true, configurable: true, get: function() {
+    var results = [];
+
+    // committee reports
+    var count = 0;
+    var link = null;
+
+    Agenda.index.forEach(function(item) {
+      if (/^[A-Z]+$/.test(item.attach)) {
+        count++;
+        link = link || item.href
+      }
+    });
+
+    results.push({
+      color: "available",
+      count: count,
+      href: link,
+      text: "committee reports"
+    });
+
+    // special orders
+    count = 0;
+    link = null;
+
+    Agenda.index.forEach(function(item) {
+      if (/^7[A-Z]+$/.test(item.attach)) {
+        count++;
+        link = link || item.href
+      }
+    });
+
+    results.push({
+      color: "available",
+      count: count,
+      href: link,
+      text: "special orders"
+    });
+
+    // awaiting preapprovals
+    count = 0;
+
+    Agenda.index.forEach(function(item) {
+      if (item.color == "ready") count++
+    });
+
+    results.push({
+      color: "ready",
+      count: count,
+      href: "queue",
+      text: "awaiting preapprovals"
+    });
+
+    // flagged reports
+    count = 0;
+
+    Agenda.index.forEach(function(item) {
+      if (item.flagged_by) count++
+    });
+
+    results.push({
+      color: "commented",
+      count: count,
+      href: "flagged",
+      text: "flagged reports"
+    });
+
+    // missing reports
+    count = 0;
+
+    Agenda.index.forEach(function(item) {
+      if (item.missing) count++
+    });
+
+    results.push({
+      color: "missing",
+      count: count,
+      href: "missing",
+      text: "missing reports"
+    });
+
+    return results
+  }}
+});
+
+Object.defineProperties(Agenda.prototype, {
+  //
+  // Methods on individual agenda items
+  //
+  // default view for an individual agenda item
+  view: {enumerable: true, configurable: true, get: function() {
+    if (this._title == "Action Items") {
+      return (this._text || Minutes.started ? ActionItems : SelectActions)
+    } else if (this._title == "Roll Call" && Server.role == "secretary") {
+      return RollCall
+    } else if (this._title == "Adjournment" && Server.role == "secretary") {
+      return Adjournment
+    } else {
+      return Report
+    }
+  }},
+
+  // buttons and forms to show with this report
+  buttons: {enumerable: true, configurable: true, get: function() {
+    var list = [];
+
+    if (this._comments !== undefined && !Minutes.complete) {
+      // some reports don't have comments
+      if (this.pending) {
+        list.push({form: AddComment, text: "edit comment"})
+      } else {
+        list.push({form: AddComment, text: "add comment"})
+      }
+    };
+
+    if (this._title == "Roll Call") list.push({button: Attend});
+
+    if (/^(\d|7?[A-Z]+|4[A-Z])$/.test(this._attach) && !Minutes.complete) {
+      if (this.missing) {
+        list.push({form: Post, text: "post report"})
+      } else if (/^7\w/.test(this._attach)) {
+        list.push({form: Post, text: "edit resolution"})
+      } else {
+        list.push({form: Post, text: "edit report"})
+      }
+    };
+
+    if (Server.role == "director") {
+      if (!this.missing && this._comments !== undefined && !Minutes.complete) {
+        list.push({button: Approve})
+      }
+    } else if (Server.role == "secretary") {
+      if (/^7\w/.test(this._attach)) {
+        list.push({form: Vote})
+      } else if (Minutes.get(this._title)) {
+        list.push({form: AddMinutes, text: "edit minutes"})
+      } else if (["Call to order", "Adjournment"].indexOf(this._title) != -1) {
+        list.push({button: Timestamp})
+      } else {
+        list.push({form: AddMinutes, text: "add minutes"})
+      };
+
+      if (/^3\w/.test(this._attach)) {
+        if (Minutes.get(this._title) == "approved" && Server.drafts.indexOf((this._text.match(/board_minutes_\w+\.txt/) || [])[0]) != -1) {
+          list.push({form: PublishMinutes})
+        }
+      } else if (this._title == "Adjournment") {
+        if (Minutes.ready_to_post_draft) list.push({form: DraftMinutes})
+      }
+    };
+
+    return list
+  }},
+
+  // determine if this item is flagged, accounting for pending actions
+  flagged: {enumerable: true, configurable: true, get: function() {
+    if (Pending.flagged.indexOf(this._attach) != -1) return true;
+    if (!this._flagged_by) return false;
+
+    if (this._flagged_by.length == 1 && this._flagged_by[0] == Server.initials && Pending.unflagged.indexOf(this._attach) != -1) {
+      return false
+    };
+
+    return this._flagged_by.length != 0
+  }},
+
+  // banner color for this agenda item
+  color: {enumerable: true, configurable: true, get: function() {
+    if (!this._title) {
+      return "blank"
+    } else if (this._warnings) {
+      return "missing"
+    } else if (this.missing) {
+      return "missing"
+    } else if (this._approved) {
+      if (this.flagged) {
+        return "commented"
+      } else if (this._approved.length < 5) {
+        return "ready"
+      } else {
+        return "reviewed"
+      }
+    } else if (this._text || this._report) {
+      return "available"
+    } else if (this._text === undefined) {
+      return "missing"
+    } else {
+      return "reviewed"
+    }
+  }}
+});
+
+Events.subscribe("agenda", function(message) {
+  if (message.file == Agenda.file) Agenda.fetch(null, message.digest)
+});
+
+Events.subscribe("server", function(message) {
+  if (message.drafts) Server.drafts = message.drafts;
+  if (message.agendas) Server.agendas = message.agendas
+});
+
+//
+// This is the client model for draft Minutes.
+//
+function Minutes() {};
+Minutes._list = {};
+
+// (re)-load minutes
+Minutes.load = function(list) {
+  Minutes._list = {};
+
+  if (list) {
+    for (var title in list) {
+      Minutes._list[title] = list[title]
+    }
+  };
+
+  Minutes._list.attendance = Minutes._list.attendance || {}
+};
+
+// list of actions created during the meeting
+Object.defineProperty(
+  Minutes,
+  "actions",
+
+  {enumerable: true, configurable: true, get: function() {
+    var actions = [];
+
+    for (var title in Minutes._list) {
+      var minutes = Minutes._list[title] + "\n\n";
+      var pattern = /^(?:@|AI\s+)(\w+):?\s+([\s\S]*?)(\n\n|$)/g;
+      var match = pattern.exec(minutes);
+
+      while (match) {
+        actions.push({
+          owner: match[1],
+          text: match[2],
+          item: Agenda.find(title.replace(/\W/g, "-"))
+        });
+
+        match = pattern.exec(minutes)
+      }
+    };
+
+    return actions
+  }}
+);
+
+// fetch minutes for a given agenda item, by title
+Minutes.get = function(title) {
+  return Minutes._list[title]
+};
+
+Object.defineProperties(Minutes, {
+  attendees: {enumerable: true, configurable: true, get: function() {
+    return Minutes._list.attendance
+  }},
+
+  // return a list of actual or expected attendee names
+  attendee_names: {
+    enumerable: true,
+    configurable: true,
+
+    get: function() {
+      var names = [];
+      var attendance = Object.keys(Minutes._list.attendance);
+      var rollcall, pattern;
+
+      if (attendance.length == 0) {
+        rollcall = Minutes.get("Roll Call") || Agenda.find("Roll-Call").text;
+        pattern = /\n ( [a-z]*[A-Z][a-zA-Z]*\.?)+/g;
+
+        while (match = pattern.exec(rollcall)) {
+          var name = match[0].replace(/^\s+/, "").split(" ")[0];
+          if (names.indexOf(name) == -1) names.push(name)
+        }
+      } else {
+        attendance.forEach(function(name) {
+          if (!Minutes._list.attendance[name].present) return;
+          name = name.split(" ")[0];
+          if (names.indexOf(name) == -1) names.push(name)
+        })
+      };
+
+      return names.sort()
+    }
+  },
+
+  // return a list of directors present
+  directors_present: {
+    enumerable: true,
+    configurable: true,
+
+    get: function() {
+      var rollcall = Minutes.get("Roll Call") || Agenda.find("Roll-Call").text;
+
+      return (rollcall.match(/Directors.*Present:\n\n((.*\n)*?)\n/) || [])[1].replace(
+        /\n$/,
+        ""
+      )
+    }
+  },
+
+  // determine if the meeting has started
+  started: {enumerable: true, configurable: true, get: function() {
+    return Minutes._list.started
+  }},
+
+  // determine if the meeting is over
+  complete: {enumerable: true, configurable: true, get: function() {
+    return Minutes._list.complete
+  }},
+
+  // determine if the draft is ready
+  ready_to_post_draft: {
+    enumerable: true,
+    configurable: true,
+
+    get: function() {
+      return this.complete && Server.drafts.indexOf(Agenda.file.replace(
+        "_agenda_",
+        "_minutes_"
+      )) == -1
+    }
+  }
+});
+
+Events.subscribe("minutes", function(message) {
+  if (message.agenda == Agenda.file) Minutes.load(message.value)
+});
+
+function Chat() {};
+Chat._log = [];
+Chat._topic = {};
+Chat.fetch_requested = false;
+Chat.backlog_fetched = false;
+
+// as it says: fetch backlog of chat messages from the server
+Chat.fetch_backlog = function() {
+  if (Chat.fetch_requested) return;
+
+  retrieve(
+    "chat/" + (Agenda.file.match(/\d[\d_]+/) || [])[0],
+    "json",
+
+    function(messages) {
+      messages.forEach(function(message) {
+        Chat.add(message)
+      });
+
+      Chat.backlog_fetched = true
+    }
+  );
+
+  Chat.fetch_requested = true;
+  this.countdown();
+  setInterval(this.countdown, 30000)
+};
+
+// set topic to meeting status
+Chat.countdown = function() {
+  var status = Chat.status;
+  if (status) Chat.setTopic({subtype: "status", user: "whimsy", text: status})
+};
+
+// replace topic locally
+Chat.setTopic = function(entry) {
+  if (Chat._topic.text == entry.text) return;
+
+  Chat._log = Chat._log.filter(function(item) {
+    return item.type != "topic"
+  });
+
+  entry.type = "topic";
+  Chat._topic = entry;
+  Chat.add(entry);
+  if (entry.subtype == "status") Main.refresh()
+};
+
+// change topic globally
+Chat.changeTopic = function(entry) {
+  if (Chat._topic.text == entry.text) return;
+  entry.type = "topic";
+  entry.agenda = Agenda.file;
+  post("message", entry, function(message) {Chat.setTopic(entry)})
+};
+
+// return the chat log
+Object.defineProperty(
+  Chat,
+  "log",
+
+  {enumerable: true, configurable: true, get: function() {
+    return Chat._log
+  }}
+);
+
+// add an entry to the chat log
+Chat.add = function(entry) {
+  entry.timestamp = entry.timestamp || new Date().getTime();
+
+  if (Chat._log.length == 0 || Chat._log[Chat._log.length - 1].timestamp < entry.timestamp) {
+    Chat._log.push(entry)
+  } else {
+    for (var i = 0; i < Chat._log.length; i++) {
+      if (entry.timestamp <= Chat._log[i].timestamp) {
+        if (entry.timestamp != Chat._log[i].timestamp || entry.text != Chat._log[i].text) {
+          Chat._log.splice(i, 0, entry)
+        };
+
+        break
+      }
+    }
+  }
+};
+
+// meeting status for countdown
+Object.defineProperty(
+  Chat,
+  "status",
+
+  {enumerable: true, configurable: true, get: function() {
+    var diff = Agenda.find("Call-to-order").timestamp - new Date().getTime();
+
+    if (Minutes.complete) {
+      return "meeting has completed"
+    } else if (Minutes.started) {
+      return (Chat._topic.subtype == "status" ? Chat._topic.text : "meeting has started")
+    } else if (diff > 86400000 * 3 / 2) {
+      return "meeting will start in about " + Math.floor(diff / 86400000 + 0.5) + " days"
+    } else if (diff > 3600000 * 3 / 2) {
+      return "meeting will start in about " + Math.floor(diff / 3600000 + 0.5) + " hours"
+    } else if (diff > 300000) {
+      return "meeting will start in about " + Math.floor(diff / 300000 + 0.5) * 5 + " minutes"
+    } else if (diff > 90000) {
+      return "meeting will start in about " + Math.floor(diff / 60000 + 0.5) + " minutes"
+    } else {
+      return "meeting will start shortly"
+    }
+  }}
+);
+
+// subscriptions
+Events.subscribe("chat", function(message) {
+  if (message.agenda == Agenda.file) {
+    delete message[agenda];
+    Chat.add(message)
+  }
+});
+
+Events.subscribe("info", function(message) {
+  if (message.agenda == Agenda.file) {
+    delete message[agenda];
+    Chat.add(message)
+  }
+});
+
+Events.subscribe("topic", function(message) {
+  if (message.agenda == Agenda.file) Chat.setTopic(message)
+});
+
+Events.subscribe("arrive", function(message) {
+  Server.online = message.present;
+
+  Chat.add({
+    type: "info",
+    user: message.user,
+    timestamp: message.timestamp,
+    text: "joined the chat"
+  })
+});
+
+Events.subscribe("depart", function(message) {
+  Server.online = message.present;
+
+  Chat.add({
+    type: "info",
+    user: message.user,
+    timestamp: message.timestamp,
+    text: "left the chat"
+  })
+});
+
+//
+// Fetch, retain, and query the list of JIRA projects
+//
+function JIRA() {};
+JIRA._list = null;
+
+JIRA.find = function(name) {
+  if (JIRA._list) {
+    return JIRA._list.indexOf(name) != -1
+  } else {
+    JIRA._list = [];
+    JSONStorage.fetch("jira", function(list) {JIRA._list = list})
+  }
+};
+
+//
+// Provide a thin (and quite possibly unnecessary) interface to the
+// Server.pending data structure.
+//
+function Pending() {};
+
+Pending.load = function(value) {
+  if (value) Server.pending = value;
+  Main.refresh();
+  return value
+};
+
+Object.defineProperties(Pending, {
+  count: {enumerable: true, configurable: true, get: function() {
+    return Object.keys(this.comments).length + Object.keys(this.approved).length + Object.keys(this.unapproved).length + Object.keys(this.flagged).length + Object.keys(this.unflagged).length + Object.keys(this.status).length
+  }},
+
+  comments: {enumerable: true, configurable: true, get: function() {
+    return (Server.pending ? Server.pending.comments : [])
+  }},
+
+  approved: {enumerable: true, configurable: true, get: function() {
+    return Server.pending.approved
+  }},
+
+  unapproved: {enumerable: true, configurable: true, get: function() {
+    return Server.pending.unapproved
+  }},
+
+  flagged: {enumerable: true, configurable: true, get: function() {
+    return Server.pending.flagged
+  }},
+
+  unflagged: {enumerable: true, configurable: true, get: function() {
+    return Server.pending.unflagged
+  }},
+
+  seen: {enumerable: true, configurable: true, get: function() {
+    return Server.pending.seen
+  }},
+
+  initials: {enumerable: true, configurable: true, get: function() {
+    return Server.pending.initials || Server.initials
+  }},
+
+  status: {enumerable: true, configurable: true, get: function() {
+    return Server.pending.status || []
+  }}
+});
+
+// find a pending status update that matches a given action item
+Pending.find_status = function(action) {
+  var match = null;
+
+  Pending.status.forEach(function(status) {
+    var found = true;
+
+    for (var name in action) {
+      if (name != "status" && action[name] != status[name]) found = false
+    };
+
+    if (found) match = status
+  });
+
+  return match
+};
+
+Events.subscribe("pending", function(message) {
+  Pending.load(message.value)
+});
+
+// Posted PMC reports - see https://whimsy.apache.org/board/posted-reports
+function Posted() {};
+Posted._list = [];
+Posted._fetched = false;
+
+Posted.get = function(title) {
+  var results = [];
+
+  // fetch list of reports on first reference
+  if (!Posted._fetched) {
+    Posted._list = [];
+
+    JSONStorage.fetch("posted-reports", function(list) {
+      Posted._list = list
+    });
+
+    Posted._fetched = true
+  };
+
+  // return list of matching reports
+  Posted._list.forEach(function(entry) {
+    if (entry.title == title) results.push(entry)
+  });
+
+  return results
+};
+
+//
+// Fetch, retain, and query the list of historical comments
+//
+function HistoricalComments() {};
+HistoricalComments._comments = null;
+
+// find historical comments based on report title
+HistoricalComments.find = function(title) {
+  if (HistoricalComments._comments) {
+    return HistoricalComments._comments[title]
+  } else {
+    HistoricalComments._comments = {};
+
+    JSONStorage.fetch("historical-comments", function(comments) {
+      HistoricalComments._comments = comments || {}
+    })
+  }
+};
+
+// find link for historical comments based on date and report title
+HistoricalComments.link = function(date, title) {
+  if (Server.agendas.indexOf("board_agenda_" + date + ".txt") != -1) {
+    return "../" + date.replace(/_/g, "-") + "/" + title
+  } else {
+    return "../../minutes/" + title + ".html#minutes_" + date
+  }
+};
+
+//
+// Originally defined to simplify access to sessionStorage for JSON objects.
+//
+// Now expanded to include caching using fetch and the cache defined in
+// the Service Workers specification (but without the user of SWs).
+//
+function JSONStorage() {};
+
+// determine sessionStorage variable prefix based on url up to the date
+Object.defineProperty(
+  JSONStorage,
+  "prefix",
+
+  {enumerable: true, configurable: true, get: function() {
+    if (JSONStorage._prefix) return JSONStorage._prefix;
+    var base = document.getElementsByTagName("base")[0].href;
+    var origin = location.origin;
+
+    if (!origin) {
+      origin = window.location.protocol + "//" + window.location.hostname + ((window.location.port ? ":" + window.location.port : ""))
+    };
+
+    JSONStorage._prefix = base.slice(origin.length, base.length).replace(
+      new RegExp("/\\d{4}-\\d\\d-\\d\\d/.*"),
+      ""
+    ).replace(/^\W+|\W+$/g, "").replace(/\W+/g, "_") || location.port
+  }}
+);
+
+// store an item, converting it to JSON
+JSONStorage.put = function(name, value) {
+  name = JSONStorage.prefix + "-" + name;
+
+  try {
+    sessionStorage.setItem(name, JSON.stringify(value))
+  } catch (e) {
+
+  };
+  return value
+};
+
+// retrieve an item, converting it back to an object
+JSONStorage.get = function(name) {
+  if (typeof sessionStorage !== 'undefined') {
+    name = JSONStorage.prefix + "-" + name;
+    return JSON.parse(sessionStorage.getItem(name) || "null")
+  }
+};
+
+// retrieve an cached object.  Note: block may be dispatched twice,
+// once with slightly stale data and once with current data
+//
+// Note: caches only work currently on Firefox and Chrome.  All
+// other browsers fall back to XMLHttpRequest (AJAX).
+JSONStorage.fetch = function(name, block) {
+  if (typeof fetch !== 'undefined' && typeof caches !== 'undefined' && (location.protocol == "https:" || location.hostname == "localhost")) {
+    caches.open("board/agenda").then(function(cache) {
+      var fetched = null;
+      clock_counter++;
+
+      // construct request
+      var request = new Request("../json/" + name, {
+        method: "get",
+        credentials: "include",
+        headers: {Accept: "application/json"}
+      });
+
+      // dispatch request
+      fetch(request).then(function(response) {
+        cache.put(request, response.clone());
+
+        response.json().then(function(json) {
+          if (!fetched || JSON.stringify(fetched) != JSON.stringify(json)) {
+            if (!fetched) clock_counter--;
+            fetched = json;
+            if (json) block(json);
+            Main.refresh()
+          }
+        })
+      });
+
+      // check cache
+      cache.match("../json/" + name).then(function(response) {
+        if (response && !fetched) {
+          response.json().then(function(json) {
+            clock_counter--;
+            fetched = json;
+            if (json) block(json);
+            Main.refresh()
+          })
+        }
+      })
+    })
+  } else if (typeof XMLHttpRequest !== 'undefined') {
+    // retrieve from the network only
+    retrieve(name, "json", block)
+  }
+}
\ No newline at end of file
diff --git a/www/board/agenda/react.rb b/www/board/agenda/react.rb
new file mode 100644
index 0000000..e0969da
--- /dev/null
+++ b/www/board/agenda/react.rb
@@ -0,0 +1,116 @@
+# redirect root (minus trailing slash) to latest agenda
+get "/react" do
+  agenda = dir('board_agenda_*.txt').sort.last
+  redirect "#{request.path}/#{agenda[/\d+_\d+_\d+/].gsub('_', '-')}/"
+end
+
+# redirect root to latest agenda
+get '/react/' do
+  agenda = dir('board_agenda_*.txt').sort.last
+  redirect "#{request.path}#{agenda[/\d+_\d+_\d+/].gsub('_', '-')}/"
+end
+
+# redirect missing to missing page for the latest agenda
+get '/react/missing' do
+  agenda = dir('board_agenda_*.txt').sort.last
+  response.headers['Location'] = 
+    "/react/#{agenda[/\d+_\d+_\d+/].gsub('_', '-')}/missing"
+  status 302
+end
+
+# all agenda pages
+get %r{/react/(\d\d\d\d-\d\d-\d\d)/(.*)} do |date, path|
+  agenda = "board_agenda_#{date.gsub('-','_')}.txt"
+  pass unless Agenda.parse agenda, :quick
+
+  @base = "#{env['SCRIPT_NAME']}/react/#{date}/"
+
+  if env['REMOTE_USER']
+    userid = env['REMOTE_USER']
+  elsif ENV['RACK_ENV'] == 'test'
+    userid = env['HTTP_REMOTE_USER'] || 'test'
+  elsif env.respond_to? :user
+    userid = env.user
+  else
+    require 'etc'
+    userid = Etc.getlogin
+  end
+
+  if userid == 'test' and ENV['RACK_ENV'] == 'test'
+    username = 'Joe Tester'
+  else
+    username = ASF::Person.new(userid).public_name
+    username ||= Etc.getpwnam(userid)[4].split(',')[0].force_encoding('utf-8')
+  end
+
+  pending = Pending.get(userid)
+  initials = pending['initials'] || username.gsub(/[^A-Z]/, '').downcase
+
+  if userid == 'test' or ASF::Service['board'].members.map(&:id).include? userid
+    role = :director
+  elsif ASF::Service['asf-secretary'].members.map(&:id).include? userid
+    role = :secretary
+  else
+    role = :guest
+  end
+
+  # determine who is present
+  @present = []
+  @present_mtime = nil
+  file = File.join(AGENDA_WORK, 'sessions', 'present.yml')
+  if File.exist?(file) and File.mtime(file) != @present_mtime
+    @present_mtime = File.mtime(file)
+    @present = YAML.load_file(file)
+  end
+
+  @server = {
+    userid: userid,
+    agendas: dir('board_agenda_*.txt').sort,
+    drafts: dir('board_minutes_*.txt').sort,
+    pending: pending,
+    username: username,
+    firstname: username.split(' ').first.downcase,
+    initials: initials,
+    online: @present,
+    session: Session.user(userid),
+    role: role,
+    directors: Hash[ASF::Service['board'].members.map {|person| 
+      initials = person.public_name.gsub(/[^A-Z]/, '').downcase
+      [initials, person.public_name.split(' ').first]
+    }],
+    websocket: (env['rack.url_scheme'].sub('http', 'ws')) + '://' +
+      env['SERVER_NAME'] + env['SCRIPT_NAME'] + '/websocket/'
+  }
+
+  @page = {
+    path: path,
+    query: params['q'],
+    agenda: agenda,
+    parsed: Agenda[agenda][:parsed],
+    digest: Agenda[agenda][:digest],
+    etag: Agenda.uptodate(agenda) ? Agenda[agenda][:etag] : nil
+  }
+
+  minutes = AGENDA_WORK + '/' + 
+    agenda.sub('agenda', 'minutes').sub('.txt', '.yml')
+  @page[:minutes] = YAML.load(File.read(minutes)) if File.exist? minutes
+
+  @cssmtime = File.mtime('public/stylesheets/app.css').to_i
+  @appmtime = File.mtime('public/react/app.js').to_i
+
+  erb :"react/scaffold.html"
+end
+
+# append slash to agenda page if not present
+get %r{/react/(\d\d\d\d-\d\d-\d\d)} do |date|
+  redirect to("/react/#{date}/")
+end
+
+# internally redirect the rest to the main routes
+get %r{/react(\/.*)} do |path|
+  call env.merge!("PATH_INFO" => path)
+end
+
+post %r{/react(\/.*)} do |path|
+  call env.merge!("PATH_INFO" => path)
+end
diff --git a/www/board/agenda/spec/client_spec.rb b/www/board/agenda/spec/client_spec.rb
index 35e4b14..2ef240a 100644
--- a/www/board/agenda/spec/client_spec.rb
+++ b/www/board/agenda/spec/client_spec.rb
@@ -5,9 +5,9 @@
 #
 
 require_relative 'spec_helper'
-require_relative 'react_server'
+require_relative 'vue_server'
 
-describe "client", type: :feature, server: :react do
+describe "client", type: :feature, server: :vue do
   #
   # Agenda model
   #
@@ -15,15 +15,21 @@
     it "should link pages in agenda traversal order" do
       @parsed = Agenda.parse 'board_agenda_2015_02_18.txt', :quick
 
-      on_react_server do
-        agenda = Agenda.load(@parsed)
+      on_vue_server do
 
-        output = _div agenda do |item|
-          _item.next item.next.href, class: item.href if item.next
-          _item.prev item.prev.href, class: item.href if item.prev
+        agenda = Agenda.load(@parsed)
+        container = document.createElement('div')
+
+        class TestClient < Vue
+          def render
+            _div agenda do |item|
+              _item.next item.next.href, class: item.href if item.next
+              _item.prev item.prev.href, class: item.href if item.prev
+            end
+          end
         end
 
-        response.end ReactDOMServer.renderToStaticMarkup(output)
+        Vue.renderResponse(TestClient, response)
       end
 
       expect(page).not_to have_selector '.Call-to-order.prev'
diff --git a/www/board/agenda/spec/filters_spec.rb b/www/board/agenda/spec/filters_spec.rb
index 6d97a80..31bf6a1 100644
--- a/www/board/agenda/spec/filters_spec.rb
+++ b/www/board/agenda/spec/filters_spec.rb
@@ -1,7 +1,7 @@
 require_relative 'spec_helper'
-require_relative 'react_server'
+require_relative 'vue_server'
 
-describe "filters", type: :feature, server: :react do
+describe "filters", type: :feature, server: :vue do
   before :all do
     @parsed = Agenda.parse 'board_agenda_2015_02_18.txt', :quick
   end
@@ -13,11 +13,16 @@
     it "should convert http addresses to links" do
       @item = @parsed.find {|item| item['title'] == 'Clerezza'}
 
-      on_react_server do
-        container = document.createElement('div')
-        ReactDOM.render _Report(item: Agenda.new(@item)), container do
-          response.end container.innerHTML
+      on_vue_server do
+        agenda_item = Agenda.new(@item)
+
+        class TestReport < Vue
+          def render
+            _Report item: agenda_item
+          end
         end
+
+        Vue.renderResponse TestReport, response
       end
 
       expect(page).to have_selector 'a[href="http://s.apache.org/EjO"]'
@@ -31,11 +36,16 @@
     it "should convert start time to local time on call to order" do
       @item = @parsed.find {|item| item['title'] == 'Call to order'}
 
-      on_react_server do
-        container = document.createElement('div')
-        ReactDOM.render _Report(item: Agenda.new(@item)), container do
-          response.end container.innerHTML
+      on_vue_server do
+        agenda_item = Agenda.new(@item)
+
+        class TestReport < Vue
+          def render
+            _Report item: agenda_item
+          end
         end
+
+        Vue.renderResponse TestReport, response
       end
 
       expect(page).to have_selector 'span.hilite', text: /Local Time:/
@@ -52,11 +62,16 @@
         rubys: {name: "Sam Ruby", member: true, attending: true}
       })
 
-      on_react_server do
-        container = document.createElement('div')
-        ReactDOM.render _Report(item: Agenda.new(@item)), container do
-          response.end container.innerHTML
+      on_vue_server do
+        agenda_item = Agenda.new(@item)
+
+        class TestReport < Vue
+          def render
+            _Report item: agenda_item
+          end
         end
+
+        Vue.renderResponse TestReport, response
       end
 
       expect(page).to have_selector \
diff --git a/www/board/agenda/spec/forms_spec.rb b/www/board/agenda/spec/forms_spec.rb
index 8783a7f..c406ef4 100644
--- a/www/board/agenda/spec/forms_spec.rb
+++ b/www/board/agenda/spec/forms_spec.rb
@@ -3,20 +3,22 @@
 #
 
 require_relative 'spec_helper'
-require_relative 'react_server'
+require_relative 'vue_server'
 
-describe "forms", type: :feature, server: :react do
+describe "forms", type: :feature, server: :vue do
   #
   # Comment form
   #
   describe "comment form" do
     it "has an add-comment form with a disabled Save button" do
-      on_react_server do
-        server = {pending: {}, initials: 'sr'}
-        container = document.createElement('div')
-        ReactDOM.render _AddComment(item: {}, server: server), container do
-          response.end container.innerHTML
+      on_vue_server do
+        class TestCommentForm < Vue
+          def render
+            _AddComment(item: {}, server: {pending: {}, initials: 'sr'})
+          end
         end
+
+        Vue.renderResponse(TestCommentForm, response)
       end
 
       expect(page).to have_selector '.modal#comment-form'
@@ -30,15 +32,21 @@
     end
 
     it "should enable Save button after input" do
-      on_react_server do
+      on_vue_server do
+        item = {}
         server = {pending: {}, initials: 'sr'}
-        container = document.createElement('div')
-        ReactDOM.render _AddComment(item: {}, server: server), container do
-          node = container.querySelector('#comment-text')
-          node.textContent = 'Good job!'
-          Simulate.change node, target: {value: 'Good job!'}
-          response.end container.innerHTML
+
+        class TestCommentForm < Vue
+          def render
+            _AddComment(item: item, server: server)
+          end
         end
+
+        app = Vue.renderApp(TestCommentForm)
+        node = app.querySelector('#comment-text')
+        node.value = 'Good job!'
+        node.dispatchEvent(Event.new('input'))
+        Vue.nextTick { response.end app.outerHTML }
       end
 
       expect(page).to have_selector '.modal-footer .btn-warning', text: 'Delete'
@@ -54,12 +62,16 @@
     it "should indicate when a reflow is needed" do
       parsed = Agenda.parse 'board_agenda_2015_02_18.txt', :quick
       @item = parsed.find {|item| item['title'] == 'Executive Vice President'}
-      on_react_server do
+      on_vue_server do
         item = Agenda.new(@item)
-        container = document.createElement('div')
-        ReactDOM.render _Post(item: item, button: 'edit report'), container do
-          response.end container.innerHTML
+
+        class TestPostForm < Vue
+          def render
+            _Post(item: item, button: 'edit report')
+          end
         end
+
+        Vue.renderResponse(TestPostForm, response)
       end
 
       expect(find('#post-report-text').value).to match(/to answer\nquestions/)
@@ -70,15 +82,19 @@
     it "should perform a reflow" do
       parsed = Agenda.parse 'board_agenda_2015_02_18.txt', :quick
       @item = parsed.find {|item| item['title'] == 'Executive Vice President'}
-      on_react_server do
+      on_vue_server do
         item = Agenda.new(@item)
-        container = document.createElement('div')
-        ReactDOM.render _Post(item: item, button: 'edit report'), container do
-          Simulate.click container.querySelector('.btn-danger')
-          post_report = container.querySelector('#post-report-text')
-          post_report.textContent = this.state.report
-          response.end container.innerHTML
+
+        class TestPost < Vue
+          def render
+            _Post(item: item, button: 'edit report')
+          end
         end
+
+        app = Vue.renderApp(TestPost)
+        button = app.querySelector('.btn-danger')
+        button.dispatchEvent(Event.new('click'))
+        Vue.nextTick { response.end app.outerHTML }
       end
 
       expect(find('#post-report-text').value).to match(/to\nanswer questions/)
@@ -93,13 +109,17 @@
   describe "commit form" do
     it "should generate a default commit message" do
       @parsed = Agenda.parse 'board_agenda_2015_02_18.txt', :quick
-      on_react_server do
+      on_vue_server do
         Agenda.load(@parsed)
         server = {pending: {approved: ['7'], comments: {I: 'Nice report!'}}}
-        container = document.createElement('div')
-        ReactDOM.render _Commit(item: {}, server: server), container do
-          response.end container.innerHTML
+
+        class TestCommit < Vue
+          def render
+            _Commit(item: {}, server: server)
+          end
         end
+
+        Vue.renderResponse TestCommit, response
       end
 
       expect(page).to have_selector '#commit-text',
diff --git a/www/board/agenda/spec/react_server.rb b/www/board/agenda/spec/react_server.rb
deleted file mode 100644
index d628c4e..0000000
--- a/www/board/agenda/spec/react_server.rb
+++ /dev/null
@@ -1,146 +0,0 @@
-#
-# This class spawns a io.js process to run a HTTP server which accepts
-# POST requests containing React TestUtils scripts and responds with 
-# HTML results.  It provides a Rack interface, enabling this server to
-# be run with Capybara/RackTest.
-#
-
-require 'ruby2js'
-require 'net/http'
-require 'stringio'
-
-require 'capybara/rspec'
-require 'ruby2js/filter/react'
-
-class ReactServer
-  @@pid = nil
-  @@port = nil
-
-  # start a new server
-  def self.start
-    return if @@pid
-
-    # select an available port
-    server = TCPServer.new('127.0.0.1', 0)
-    @@port = server.addr[1]
-    server.close
-
-    # spawn a server process
-    nodejs = (`which nodejs`.empty? ? 'node' : 'nodejs')
-    @@pid = spawn(nodejs, '-e', 
-      Ruby2JS.convert(@@server, {ivars: {:@port => @@port}}))
-
-    # wait for server to start
-    (0..10).each do |i|
-      begin
-        response = new.call('rack.input' => StringIO.new("response.end('hi')"))
-        return if response.first == '200' and response.last == 'hi'
-        STDERR.puts response
-        raise RuntimeError('Invalid ReactServer response received')
-      rescue Errno::ECONNREFUSED
-        sleep i * 0.1
-      end
-    end
-  end
-
-  # rack compatible interface
-  def call(env)
-    http = Net::HTTP.new('localhost', @@port)
-    request = Net::HTTP::Post.new('/', {})
-    request.body = env['rack.input'].read
-    response = http.request(request)
-    [response.code, response.to_hash(), response.body]
-  end
-
-  # stop server
-  def self.stop
-    return unless @@pid
-
-    begin
-      http = Net::HTTP.new('localhost', @@port)
-      request = Net::HTTP::Post.new('/', {})
-      request.body = "response.end('bye'); process.exit(0)"
-      response = http.request(request)
-    rescue Errno::ECONNREFUSED
-      nil
-    ensure
-      Process.wait(@@pid)
-      @@pid = nil
-    end
-  end
-
-  # the server itself
-  @@server = proc do
-    JSDOM = require("jsdom").JSDOM
-    global.window = JSDOM.new('<html><body></body></html>').window
-    global.document = global.window.document
-    global.navigator = global.window.navigator
-
-    React = require('react')
-    ReactDOM = require('react-dom')
-    ReactDOMServer = require('react-dom/server');
-    TestUtils = require('react-dom/test-utils')
-    Simulate = TestUtils.Simulate
-
-    jQuery = require('jquery')
-
-    http = require('http')
-    server = http.createServer do |request, response|
-      data = ''
-      request.on('data') do |chunk| 
-        data += chunk
-      end
-
-      request.on 'error' do |error|
-        console.log "ReactServer error: #{error.message}"
-      end
-
-      request.on 'end' do
-        response.writeHead(200, 'Content-Type' => 'text/plain')
-
-        begin
-          eval(data)
-        rescue => error
-          response.end(error.toString());
-        end
-      end
-    end
-
-    server.listen(@port)
-  end
-end
-
-shared_context "react_server", server: :react do
-  #
-  # administrivia
-  #
-  before :all do
-    ReactServer.start
-    Dir.chdir File.expand_path('../../views', __FILE__) do
-      @_script = Ruby2JS.convert(File.read('app.js.rb'), file: 'app.js.rb')
-    end
-  end
-
-  before :each do
-    @_app, Capybara.app = Capybara.app, ReactServer.new
-  end
-
-  def on_react_server(&block)
-    locals = {}
-    instance_variables.each do |ivar|
-      next if ivar.to_s.start_with? '@_'
-      locals[ivar] = instance_variable_get(ivar)
-    end
-
-    page.driver.post('/', @_script + ';' +
-      Ruby2JS.convert(block, react: true, ivars: locals))
-  end
-
-  after :each do
-    Capybara.app = @_app
-  end
-
-  at_exit do
-    ReactServer.stop
-  end
-end
diff --git a/www/board/agenda/spec/vue_server.rb b/www/board/agenda/spec/vue_server.rb
new file mode 100644
index 0000000..0c4a3b9
--- /dev/null
+++ b/www/board/agenda/spec/vue_server.rb
@@ -0,0 +1,186 @@
+#
+# This class spawns a io.js process to run a HTTP server which accepts
+# POST requests containing Vue/jsdom scripts and responds with 
+# HTML results.  It provides a Rack interface, enabling this server to
+# be run with Capybara/RackTest.
+#
+
+require 'ruby2js'
+require 'net/http'
+require 'stringio'
+
+require 'capybara/rspec'
+require 'ruby2js/filter/vue'
+
+class VueServer
+  @@pid = nil
+  @@port = nil
+
+  # start a new server
+  def self.start
+    return if @@pid
+
+    # select an available port
+    server = TCPServer.new('127.0.0.1', 0)
+    @@port = server.addr[1]
+    server.close
+
+    # spawn a server process
+    nodejs = (`which nodejs`.empty? ? 'node' : 'nodejs')
+    @@pid = spawn(nodejs, '-e', 
+      Ruby2JS.convert(@@server, {ivars: {:@port => @@port}}))
+
+    # wait for server to start
+    (0..10).each do |i|
+      begin
+        response = new.call('rack.input' => StringIO.new("response.end('hi')"))
+        return if response.first == '200' and response.last == 'hi'
+        STDERR.puts response
+        raise RuntimeError('Invalid VueServer response received')
+      rescue Errno::ECONNREFUSED
+        sleep i * 0.1
+      end
+    end
+  end
+
+  # rack compatible interface
+  def call(env)
+    http = Net::HTTP.new('localhost', @@port)
+    request = Net::HTTP::Post.new('/', {})
+    request.body = env['rack.input'].read
+    response = http.request(request)
+    [response.code, response.to_hash(), response.body]
+  end
+
+  # stop server
+  def self.stop
+    return unless @@pid
+
+    begin
+      http = Net::HTTP.new('localhost', @@port)
+      request = Net::HTTP::Post.new('/', {})
+      request.body = "response.end('bye'); process.exit(0)"
+      response = http.request(request)
+    rescue Errno::ECONNREFUSED
+      nil
+    ensure
+      Process.wait(@@pid)
+      @@pid = nil
+    end
+  end
+
+  # the server itself
+  @@server = proc do
+    cleanup = require("jsdom-global/register")
+    delete XMLHttpRequest
+
+    process.env.VUE_ENV = 'server'
+
+    Vue = require('vue')
+    Vue.config.productionTip = false
+
+    # render a response, using server side rendering
+    def Vue.renderResponse(component, response)
+      renderer = require('vue-server-renderer').createRenderer()
+      app = Vue.new(render: proc {|h| return h(component)})
+
+      renderer.renderToString(app) do |err, html|
+        if err
+          response.end(err.toString() + "\n" + err.stack)
+        else
+          response.end(html)
+        end
+      end
+    end
+
+    # render a element, using client side rendering
+    def Vue.renderElement(component)
+      outer = document.createElement('div')
+      inner = document.createElement('span')
+      outer.appendChild(inner);
+      Vue.new(el: inner, render: proc {|h| return h(component)})
+      return outer.firstChild
+    end
+
+    # render an app, using client side rendering.  Convenience methods are
+    # provided to querySelector, and to extract outerHTML.
+    def Vue.renderApp(component)
+      outer = document.createElement('div')
+      inner = document.createElement('span')
+      outer.appendChild(inner);
+      app = Vue.new(el: inner, render: proc {|h| return h(component)})
+      inner = outer.firstChild
+
+      def app.outerHTML
+        return inner.outerHTML
+      end
+
+      def app.querySelector(selector)
+        return outer.querySelector(selector)
+      end
+
+      return app
+    end
+
+    jQuery = require('jquery')
+
+    http = require('http')
+    server = http.createServer do |request, response|
+      data = ''
+      request.on('data') do |chunk| 
+        data += chunk
+      end
+
+      request.on 'error' do |error|
+        console.log "VueServer error: #{error.message}"
+      end
+
+      request.on 'end' do
+        response.writeHead(200, 'Content-Type' => 'text/plain')
+
+        begin
+          eval(data)
+        rescue => error
+          response.end(error.toString());
+        end
+      end
+    end
+
+    server.listen(@port)
+  end
+end
+
+shared_context "vue_server", server: :vue do
+  #
+  # administrivia
+  #
+  before :all do
+    VueServer.start
+    Dir.chdir File.expand_path('../../views', __FILE__) do
+      @_script = Ruby2JS.convert(File.read('app.js.rb'), file: 'app.js.rb')
+    end
+  end
+
+  before :each do
+    @_app, Capybara.app = Capybara.app, VueServer.new
+  end
+
+  def on_vue_server(&block)
+    locals = {}
+    instance_variables.each do |ivar|
+      next if ivar.to_s.start_with? '@_'
+      locals[ivar] = instance_variable_get(ivar)
+    end
+
+    page.driver.post('/', @_script + ';' +
+      Ruby2JS.convert(block, vue: true, vue_h: '$h', ivars: locals).to_s)
+  end
+
+  after :each do
+    Capybara.app = @_app
+  end
+
+  at_exit do
+    VueServer.stop
+  end
+end
diff --git a/www/board/agenda/views/app.js.rb b/www/board/agenda/views/app.js.rb
index 1366739..c7ba655 100644
--- a/www/board/agenda/views/app.js.rb
+++ b/www/board/agenda/views/app.js.rb
@@ -1,3 +1,6 @@
+# config
+require_relative 'vue-config'
+
 # common
 require_relative 'router'
 require_relative 'keyboard'
diff --git a/www/board/agenda/views/buttons/add-comment.js.rb b/www/board/agenda/views/buttons/add-comment.js.rb
index 2445424..398b1f7 100644
--- a/www/board/agenda/views/buttons/add-comment.js.rb
+++ b/www/board/agenda/views/buttons/add-comment.js.rb
@@ -8,7 +8,7 @@
 # form is dismissed.
 #
 
-class AddComment < React
+class AddComment < Vue
   def initialize
     @base = @comment = @@item.pending
     @disabled = false
@@ -37,12 +37,11 @@
       #input field: initials
       _input.comment_initials! label: 'Initials',
         placeholder: 'initials', disabled: @disabled,
-        defaultValue: @@server.pending.initials || @@server.initials
+        value: @@server.pending.initials || @@server.initials
 
       #input field: comment text
       _textarea.comment_text!  value: @comment, label: 'Comment',
-        placeholder: 'comment', rows: 5, onChange: self.change,
-        disabled: @disabled
+        placeholder: 'comment', rows: 5, disabled: @disabled
 
       if Server.role == :director and @@item.attach =~ /^[A-Z]+$/
         _input.flag! type: 'checkbox', label: 'item requires discussion or follow up',
@@ -63,23 +62,9 @@
   end
 
   # autofocus on comment text
-  def componentDidMount()
+  def mounted()
     jQuery('#comment-form').on 'shown.bs.modal' do
-      ~'#comment-text'.focus()
-    end
-  end
-
-  # update comment when textarea changes, triggering hiding/showing the
-  # Delete button and enabling/disabling the Save button.
-  def change(event)
-    @comment = event.target.value
-  end
-
-  # when item changes, reset base and comment
-  def componentWillReceiveProps(newprops)
-    if newprops.item.href != self.props.item.href
-      @checked = newprops.item.flagged
-      @base = @comment = newprops.item.pending || ''
+      document.getElementById("comment-text").focus()
     end
   end
 
@@ -90,7 +75,7 @@
 
   # when save button is pushed, post comment and dismiss modal when complete
   def save(event)
-    Server.initials = ~'#comment_initials'.value
+    Server.initials = document.getElementById("comment-initials").value
 
     data = {
       agenda: Agenda.file,
diff --git a/www/board/agenda/views/buttons/add-minutes.js.rb b/www/board/agenda/views/buttons/add-minutes.js.rb
index 8f088bd..c0f0410 100644
--- a/www/board/agenda/views/buttons/add-minutes.js.rb
+++ b/www/board/agenda/views/buttons/add-minutes.js.rb
@@ -1,4 +1,4 @@
-class AddMinutes < React
+class AddMinutes < Vue
   def initialize
     @disabled = false
   end
@@ -68,22 +68,17 @@
   end
 
   # autofocus on minute text
-  def componentDidMount()
+  def mounted()
     jQuery('#minute-form').on 'shown.bs.modal' do
-      ~'#minute-text'.focus()
+      document.getElementById("minute-text").focus()
     end
   end
 
   # when initially displayed, set various fields to match the item
-  def componentWillMount()
+  def created()
     self.setup(@@item)
   end
 
-  # when item changes, reset various fields to match
-  def componentWillReceiveProps(newprops)
-    self.setup(newprops.item) if newprops.item.href != self.props.item.href
-  end
-
   # reset base, draft minutes, shepherd, default ai_text, and indent
   def setup(item)
     @base = draft = Minutes.get(item.title) || ''
diff --git a/www/board/agenda/views/buttons/approve.js.rb b/www/board/agenda/views/buttons/approve.js.rb
index fbd1445..94e5fed 100644
--- a/www/board/agenda/views/buttons/approve.js.rb
+++ b/www/board/agenda/views/buttons/approve.js.rb
@@ -1,7 +1,7 @@
 #
 # Approve/Unapprove a report
 #
-class Approve < React
+class Approve < Vue
   def initialize
     @disabled = false
     @request = 'approve'
@@ -12,14 +12,9 @@
     _button.btn.btn_primary @request, onClick: self.click, disabled: @disabled
   end
 
-  # set request and button text on initial load
-  def componentWillMount()
-    self.componentWillReceiveProps()
-  end
-
   # set request (and button text) depending on whether or not the
   # not this items was previously approved
-  def componentWillReceiveProps()
+  def created()
     if Pending.approved.include? @@item.attach
       @request = 'unapprove'
     elsif Pending.unapproved.include? @@item.attach
diff --git a/www/board/agenda/views/buttons/attend.js.rb b/www/board/agenda/views/buttons/attend.js.rb
index 40e5621..c7b10b4 100644
--- a/www/board/agenda/views/buttons/attend.js.rb
+++ b/www/board/agenda/views/buttons/attend.js.rb
@@ -1,7 +1,7 @@
 #
 # Indicate intention to attend / regrets for meeting
 #
-class Attend < React
+class Attend < Vue
   def initialize
     @disabled = false
   end
@@ -11,12 +11,8 @@
       onClick: self.click, disabled: @disabled
   end
 
-  def componentWillMount()
-    self.componentWillReceiveProps()
-  end
-
   # match person by either userid or name
-  def componentWillReceiveProps()
+  def created()
     person = @@item.people[Server.userid]
     if person
       @attending = person.attending
diff --git a/www/board/agenda/views/buttons/commit.js.rb b/www/board/agenda/views/buttons/commit.js.rb
index fd7264b..0a23d78 100644
--- a/www/board/agenda/views/buttons/commit.js.rb
+++ b/www/board/agenda/views/buttons/commit.js.rb
@@ -3,7 +3,7 @@
 # and allow it to be changed.
 #
 
-class Commit < React
+class Commit < Vue
   def initialize
     @disabled = false
   end
@@ -34,20 +34,15 @@
     end
   end
 
-  # set message on initial display
-  def componentWillMount()
-    self.componentWillReceiveProps()
-  end
-
   # autofocus on comment text
-  def componentDidMount()
+  def mounted()
     jQuery('#commit-form').on 'shown.bs.modal' do
-      ~'#commit-text'.focus()
+      document.getElementById("commit-text").focus()
     end
   end
 
   # update message on re-display
-  def componentWillReceiveProps()
+  def created()
     pending = @@server.pending
     messages = []
 
@@ -103,11 +98,6 @@
     @message = messages.join("\n")
   end
 
-  # update message when textarea changes
-  def change(event)
-    @message = event.target.value
-  end
-
   # on click, disable the input fields and buttons and submit
   def click(event)
     @disabled = true
diff --git a/www/board/agenda/views/buttons/draft-minutes.js.rb b/www/board/agenda/views/buttons/draft-minutes.js.rb
index bdf9765..5121195 100644
--- a/www/board/agenda/views/buttons/draft-minutes.js.rb
+++ b/www/board/agenda/views/buttons/draft-minutes.js.rb
@@ -1,4 +1,4 @@
-class DraftMinutes < React
+class DraftMinutes < Vue
   def initialize
     @disabled = true
   end
@@ -29,11 +29,11 @@
   end
 
   # autofocus on minute text; fetch draft
-  def componentDidMount()
+  def mounted()
     @draft = ''
     jQuery('#draft-minute-form').on 'shown.bs.modal' do
       retrieve "draft/#{Agenda.title.gsub('-', '_')}", :text do |draft|
-        ~'#draft-minute-text'.focus()
+        document.getElementById("draft-minute-text").focus()
         @disabled = false
         @draft = draft
         jQuery('#draft-minute-text').animate(scrollTop: 0)
diff --git a/www/board/agenda/views/buttons/email.js.rb b/www/board/agenda/views/buttons/email.js.rb
index 169a068..fa70ffc 100644
--- a/www/board/agenda/views/buttons/email.js.rb
+++ b/www/board/agenda/views/buttons/email.js.rb
@@ -2,7 +2,7 @@
 # Send email
 #
 
-class Email < React
+class Email < Vue
   def render
     _button.btn 'send email', class: self.mailto_class(),
       onClick: self.launch_email_client
diff --git a/www/board/agenda/views/buttons/markseen.js.rb b/www/board/agenda/views/buttons/markseen.js.rb
index 4d029cc..96cc182 100644
--- a/www/board/agenda/views/buttons/markseen.js.rb
+++ b/www/board/agenda/views/buttons/markseen.js.rb
@@ -1,7 +1,7 @@
 #
 # A button that mark all comments as 'seen', with an undo option
 #
-class MarkSeen < React
+class MarkSeen < Vue
   def initialize
     @disabled = false
     @label = 'mark seen'
diff --git a/www/board/agenda/views/buttons/message.js.rb b/www/board/agenda/views/buttons/message.js.rb
index f437327..e39ab8e 100644
--- a/www/board/agenda/views/buttons/message.js.rb
+++ b/www/board/agenda/views/buttons/message.js.rb
@@ -1,7 +1,7 @@
 #
 # Message area for backchannel
 #
-class Message < React
+class Message < Vue
   def initialize
     @disabled = false
     @message = ''
@@ -15,8 +15,8 @@
   end
 
   # autofocus on the chat message when the page is initially displayed
-  def componentDidMount()
-    ~'#chatMessage'.focus()
+  def mounted()
+    document.getElementById("chatMessage").focus()
   end
 
   # send message to server
diff --git a/www/board/agenda/views/buttons/post-actions.js.rb b/www/board/agenda/views/buttons/post-actions.js.rb
index f96f3ef..375337f 100644
--- a/www/board/agenda/views/buttons/post-actions.js.rb
+++ b/www/board/agenda/views/buttons/post-actions.js.rb
@@ -1,7 +1,7 @@
 #
 # Indicate intention to attend / regrets for meeting
 #
-class PostActions < React
+class PostActions < Vue
   def initialize
     @disabled = false
   end
diff --git a/www/board/agenda/views/buttons/post.js.rb b/www/board/agenda/views/buttons/post.js.rb
index 757cbd8..d5cef1d 100644
--- a/www/board/agenda/views/buttons/post.js.rb
+++ b/www/board/agenda/views/buttons/post.js.rb
@@ -4,7 +4,7 @@
 # For new resolutions, allow entry of title, but not commit message
 # For everything else, allow modification of commit message, but not title
 
-class Post < React
+class Post < Vue
   def initialize
     @disabled = false
     @alerted = false
@@ -42,7 +42,7 @@
           _div.form_group do
             _label 'financial spreadsheet from virtual', for: 'upload'
             _input.upload! type: 'file', value: @upload
-            _button.btn.btn_primary 'Upload', onClick: self.upload,
+            _button.btn.btn_primary 'Upload', onClick: upload_spreadsheet,
               disabled: @disabled || !@upload
           end
         end
@@ -62,24 +62,19 @@
     end
   end
 
-  # set properties on initial load
-  def componentWillMount()
-    self.componentWillReceiveProps()
-  end
-
   # autofocus on report/resolution title/text
-  def componentDidMount()
+  def mounted()
     jQuery('#post-report-form').on 'shown.bs.modal' do
       if @@button.text == 'add resolution'
-        ~'#post-report-title'.focus()
+        document.getElementById("post-report-title").focus()
       else
-        ~'#post-report-text'.focus()
+        document.getElementById("post-report-text").focus()
       end
     end
   end
 
   # match form title, input label, and commit message with button text
-  def componentWillReceiveProps(newprops)
+  def created(newprops)
     case @@button.text
     when 'post report'
       @header = 'Post Report'
@@ -184,7 +179,7 @@
   end
 
   # upload contents of spreadsheet in base64; append extracted table to report
-  def upload(event)
+  def upload_spreadsheet(event)
     @disabled = true
     event.preventDefault()
 
diff --git a/www/board/agenda/views/buttons/publish-minutes.js.rb b/www/board/agenda/views/buttons/publish-minutes.js.rb
index fe80bef..f2cc153 100644
--- a/www/board/agenda/views/buttons/publish-minutes.js.rb
+++ b/www/board/agenda/views/buttons/publish-minutes.js.rb
@@ -1,4 +1,4 @@
-class PublishMinutes < React
+class PublishMinutes < Vue
   def initialize
     @disabled = false
     @previous_title = nil
@@ -31,23 +31,18 @@
   end
 
   # On first load, ensure summary is produced
-  def componentWillMount()
-    self.componentWillReceiveProps()
-  end
-
-  # when page title changes, update form values
-  def componentWillReceiveProps()
+  def created()
     if @@item.title != @previous_title
       if not @@item.attach
         # Index page for a path month's agenda
-        self.summary Agenda.index, Agenda.title.gsub('-', '_')
+        self.summarize Agenda.index, Agenda.title.gsub('-', '_')
       elsif defined? XMLHttpRequest
         # Minutes from previous meetings section of the agenda
         date = @@item.text[/board_minutes_(\d+_\d+_\d+)\.txt/, 1]
         url = document.baseURI.sub(/[-\d]+\/$/, date.gsub('_', '-')) + '.json'
   
         retrieve url, :json do |agenda|
-          self.summary agenda, date
+          self.summarize agenda, date
         end
       end
 
@@ -56,14 +51,14 @@
   end
 
   # autofocus on minute text
-  def componentDidMount()
+  def mounted()
     jQuery('#publish-minutes-form').on 'shown.bs.modal' do
-      ~'#summary-text'.focus()
+      document.getElementById("summary-text").focus()
     end
   end
 
   # compute default summary for web site and commit message
-  def summary(agenda, date)
+  def summarize(agenda, date)
     summary = "- [#{self.formatDate(date)}]" +
        "(../records/minutes/#{date[0..3]}/board_minutes_#{date}.txt)\n"
 
diff --git a/www/board/agenda/views/buttons/refresh.js.rb b/www/board/agenda/views/buttons/refresh.js.rb
index 9962f5d..5a10062 100644
--- a/www/board/agenda/views/buttons/refresh.js.rb
+++ b/www/board/agenda/views/buttons/refresh.js.rb
@@ -1,7 +1,7 @@
 #
 # A button that will do a 'svn update' of the agenda on the server
 #
-class Refresh < React
+class Refresh < Vue
   def initialize
     @disabled = false
   end
diff --git a/www/board/agenda/views/buttons/reminders.js.rb b/www/board/agenda/views/buttons/reminders.js.rb
index 62e852e..7298370 100644
--- a/www/board/agenda/views/buttons/reminders.js.rb
+++ b/www/board/agenda/views/buttons/reminders.js.rb
@@ -3,7 +3,7 @@
 # associated button) as well as a second button.
 #
 
-class InitialReminder < React
+class InitialReminder < Vue
   def initialize
     @disabled = true
     @subject = ''
@@ -36,7 +36,7 @@
   end
 
   # wire up event handlers
-  def componentDidMount()
+  def mounted()
     Array(document.querySelectorAll('.btn-primary')).each do |button|
       if button.getAttribute('data-target') == '#reminder-form'
         button.onclick = self.loadText
@@ -108,7 +108,7 @@
 #
 # A button for final reminders
 #
-class FinalReminder < React
+class FinalReminder < Vue
   def render
     _button.btn.btn_primary 'send final reminders', 
       data_toggle: 'modal', data_target: '#reminder-form'
diff --git a/www/board/agenda/views/buttons/showseen.js.rb b/www/board/agenda/views/buttons/showseen.js.rb
index c3c5771..664dac5 100644
--- a/www/board/agenda/views/buttons/showseen.js.rb
+++ b/www/board/agenda/views/buttons/showseen.js.rb
@@ -1,7 +1,7 @@
 #
 # Show/hide seen items
 #
-class ShowSeen < React
+class ShowSeen < Vue
   def initialize
     @label = 'show seen'
   end
@@ -10,16 +10,20 @@
     _button.btn.btn_primary @label, onClick: self.click
   end
 
-  def componentWillReceiveProps()
+  def created()
+    self.changeLabel()
+  end
+
+  def click(event)
+    Main.view.toggleseen()
+    self.changeLabel()
+  end
+
+  def changeLabel()
     if Main.view and !Main.view.showseen()
       @label = 'hide seen'
     else
       @label = 'show seen'
     end
   end
-
-  def click(event)
-    Main.view.toggleseen()
-    self.componentWillReceiveProps()
-  end
 end
diff --git a/www/board/agenda/views/buttons/timestamp.js.rb b/www/board/agenda/views/buttons/timestamp.js.rb
index e6e73db..ca3cb5c 100644
--- a/www/board/agenda/views/buttons/timestamp.js.rb
+++ b/www/board/agenda/views/buttons/timestamp.js.rb
@@ -1,7 +1,7 @@
 #
 # Timestamp start/stop of meeting
 #
-class Timestamp < React
+class Timestamp < Vue
   def initialize
     @disabled = false
   end
diff --git a/www/board/agenda/views/buttons/vote.js.rb b/www/board/agenda/views/buttons/vote.js.rb
index ed34a12..913776a 100644
--- a/www/board/agenda/views/buttons/vote.js.rb
+++ b/www/board/agenda/views/buttons/vote.js.rb
@@ -1,4 +1,4 @@
-class Vote < React
+class Vote < Vue
   def initialize
     @disabled = false
   end
@@ -45,15 +45,10 @@
   end
 
   # when initially displayed, set various fields to match the item
-  def componentWillMount()
+  def created()
     self.setup(@@item)
   end
 
-  # when item changes, reset various fields to match
-  def componentWillReceiveProps(newprops)
-    self.setup(newprops.item) if newprops.item.href != self.props.item.href
-  end
-
   # reset base, draft minutes, directors present, and vote type
   def setup(item)
     @directors = Minutes.directors_present
diff --git a/www/board/agenda/views/elements/additional-info.js.rb b/www/board/agenda/views/elements/additional-info.js.rb
index b6d9774..3f08d7e 100644
--- a/www/board/agenda/views/elements/additional-info.js.rb
+++ b/www/board/agenda/views/elements/additional-info.js.rb
@@ -13,7 +13,7 @@
 #       are unique.
 #
 
-class AdditionalInfo < React
+class AdditionalInfo < Vue
   def render
     # special notes
     _p.notes @@item.notes if @@item.notes
@@ -113,13 +113,8 @@
     end
   end
 
-  # ensure componentWillReceiveProps is called on before first rendering
-  def componentWillMount()
-    self.componentWillReceiveProps()
-  end
-
   # determine prefix (if any)
-  def componentWillReceiveProps()
+  def created()
     if @@prefix == true
       @prefix = @@item.title.downcase() + '-'
     elsif @@prefix
diff --git a/www/board/agenda/views/elements/info.js.rb b/www/board/agenda/views/elements/info.js.rb
index be3ffe7..f2862eb 100644
--- a/www/board/agenda/views/elements/info.js.rb
+++ b/www/board/agenda/views/elements/info.js.rb
@@ -1,4 +1,4 @@
-class Info < React
+class Info < Vue
   def render
     _dl.dl_horizontal class: @@position do
       _dt 'Attach'
diff --git a/www/board/agenda/views/elements/link.js.rb b/www/board/agenda/views/elements/link.js.rb
index 96a8db9..2dd540d 100644
--- a/www/board/agenda/views/elements/link.js.rb
+++ b/www/board/agenda/views/elements/link.js.rb
@@ -3,34 +3,32 @@
 # processed locally by calling Main.navigate.
 #
 
-class Link < React
-  def initialize
-    @attrs = {}
-  end
-
-  def componentWillMount()
-    self.componentWillReceiveProps()
-    @attrs.onClick = self.click
-  end
-
-  def componentWillReceiveProps(props)
-    @text = props.text
-
-    for attr in props
-      next unless props[attr]
-      @attrs[attr] = props[attr] unless attr == 'text'
-    end
-
-    if props.href
-      @element = 'a'
-      @attrs.href = props.href.gsub(%r{(^|/)\w+/\.\.(/|$)}, '$1')
-    else
-      @element = 'span'
-    end
-  end
-
+class Link < Vue
   def render
-    React.createElement(@element, @attrs, @text)
+    Vue.createElement(element, options, @@text)
+  end
+
+  def element
+    if @@href
+      'a'
+    else
+      'span'
+    end
+  end
+
+  def options
+    result = {attrs: {}}
+
+    if @@href
+      result.attrs.href = @@href.gsub(%r{(^|/)\w+/\.\.(/|$)}, '$1')
+    end
+
+    result.attrs.rel = @@rel if @@rel
+    result.attrs.id = @@id if @@id
+
+    result.on = {click: self.click}
+
+    result 
   end
 
   def click(event)
diff --git a/www/board/agenda/views/elements/modal-dialog.js.rb b/www/board/agenda/views/elements/modal-dialog.js.rb
index e4970c7..b575512 100644
--- a/www/board/agenda/views/elements/modal-dialog.js.rb
+++ b/www/board/agenda/views/elements/modal-dialog.js.rb
@@ -5,85 +5,76 @@
 # distributed to header, body, and footer sections.
 #
 
-class ModalDialog < React
-  def initialize
-    @header = []
-    @body = []
-    @footer = []
-  end
+class ModalDialog < Vue
+  def collateSlots()
+    sections = {header: [], body: [], footer: []}
 
-  def componentWillMount()
-    self.componentWillReceiveProps()
-  end
-
-  def componentWillReceiveProps()
-    @header.clear()
-    @body.clear()
-    @footer.clear()
-
-    @@children.each do |child|
-      if child.type == 'h4'
+    $slots.default.each do |slot|
+      if slot.tag == 'h4'
 
         # place h4 elements into the header, adding a modal-title class
-        child = self.addClass(child, 'modal-title')
-        @header << child
-        ModalDialog.h4 = child
+        slot = self.addClass(slot, 'modal-title')
+        sections.header << slot
 
-      elsif child.type == 'button'
+      elsif slot.tag == 'button'
 
         # place button elements into the footer, adding a btn class
-        child = self.addClass(child, 'btn')
-        @footer << child
+        slot = self.addClass(slot, 'btn')
+        sections.footer << slot
 
-      elsif child.type == 'input' or child.type == 'textarea'
+      elsif slot.tag == 'input' or slot.tag == 'textarea'
 
         # wrap input and textarea elements in a form-control, 
         # add label if present
 
-        child = self.addClass(child, 'form-control')
+        slot = self.addClass(slot, 'form-control')
 
         label = nil
-        if child.props.label and child.props.id
-          props = {htmlFor: child.props.id}
-          if child.props.type == 'checkbox'
-            props.className = 'checkbox'
-            label = React.createElement('label', props, child,
-              child.props.label)
-            child.props.delete 'label'
-            child = nil
+        if slot.data.attrs.label and slot.data.attrs.id
+          props = {attrs: {for: slot.data.attrs.id}}
+          if slot.data.attrs.type == 'checkbox'
+            props.class = ['checkbox']
+            label = Vue.createElement('label', props, [slot,
+              slot.data.attrs.label])
+            slot.data.attrs.delete 'label'
+            slot = nil
           else
-            label = React.createElement('label', props, child.props.label)
-            child = React.cloneElement(child, label: nil)
+            label = Vue.createElement('label', props, slot.data.attrs.label)
+            slot.data.attrs.delete 'label'
           end
         end
 
-        @body << React.createElement('div', {className: 'form-group'}, 
-          label, child)
+        sections.body << Vue.createElement('div', {class: 'form-group'}, 
+          [label, slot])
 
       else
 
         # place all other elements into the body
 
-        @body << child
+        sections.body << slot
       end
     end
+
+    return sections
   end
 
   def render
+    sections = self.collateSlots()
+
     _div.modal.fade id: @@id, class: @@className do
       _div.modal_dialog do
         _div.modal_content do
           _div.modal_header class: @@color do
             _button.close "\u00d7", type: 'button', data_dismiss: 'modal'
-            _[*@header]
+            _[*sections.header]
           end
 
           _div.modal_body do
-            _[*@body]
+            _[*sections.body]
           end
 
           _div.modal_footer class: @@color do
-            _[*@footer]
+            _[*sections.footer]
           end
         end
       end
@@ -92,11 +83,11 @@
 
   # helper method: add a class to an element, returning new element
   def addClass(element, name)
-    if not element.props.className
-      element = React.cloneElement(element, className: name)
-    elsif not element.props.className.split(' ').include? name
-      element = React.cloneElement(element, 
-        className: element.props.className + " #{name}")
+    element.data ||= {}
+    if not element.data.class
+      element.data.class = [name]
+    elsif not element.data.class.include? name
+      element.data.class << name
     end
 
     return element
diff --git a/www/board/agenda/views/elements/pns.rb b/www/board/agenda/views/elements/pns.rb
index 8ef23a3..b0f2449 100644
--- a/www/board/agenda/views/elements/pns.rb
+++ b/www/board/agenda/views/elements/pns.rb
@@ -2,7 +2,7 @@
 # Determine status of podling name
 #
 
-class PodlingNameSearch < React
+class PodlingNameSearch < Vue
   def render
     _span.pns title: 'podling name search' do
       if Server.podlingnamesearch
@@ -20,7 +20,7 @@
   end
 
   # initial mount: fetch podlingnamesearch data unless already downloaded
-  def componentDidMount()
+  def mounted()
     if Server.podlingnamesearch
       self.check(self.props)
     else
@@ -31,11 +31,6 @@
     end
   end
 
-  # when properties (in particular: title) changes, lookup name again
-  def componentWillReceiveProps(newprops)
-    self.check(newprops)
-  end
-
   # lookup name in the establish resolution against the podlingnamesearches
   def check(props)
     @results = nil
diff --git a/www/board/agenda/views/elements/text.js.rb b/www/board/agenda/views/elements/text.js.rb
index 6b6ca02..f034688 100644
--- a/www/board/agenda/views/elements/text.js.rb
+++ b/www/board/agenda/views/elements/text.js.rb
@@ -2,20 +2,18 @@
 # Escape text for inclusion in HTML; optionally apply filters
 #
 
-class Text < React
-  def componentWillMount()
-    self.componentWillReceiveProps()
+class Text < Vue
+  def render
+    _span domPropsInnerHTML: text
   end
 
-  def componentWillReceiveProps()
-    @text = htmlEscape(@@raw || '')
+  def text
+    result = htmlEscape(@@raw || '')
 
     if @@filters
-      @@filters.each { |filter| @text = filter(@text) }
+      @@filters.each { |filter| result = filter(result) }
     end
-  end
 
-  def render
-    _span dangerouslySetInnerHTML: { __html: @text }
+    result
   end
 end
diff --git a/www/board/agenda/views/keyboard.js.rb b/www/board/agenda/views/keyboard.js.rb
index 4edfc1c..a3ffb80 100644
--- a/www/board/agenda/views/keyboard.js.rb
+++ b/www/board/agenda/views/keyboard.js.rb
@@ -7,19 +7,21 @@
 
     # keyboard navigation (unless on the search screen)
     def (document.body).onkeydown(event)
-      return if ~'#search-text'[0] or ~'.modal-open'[0] or ~'.modal.in'[0]
+      return if document.getElementById('search-text') or
+        document.querySelector('modal-open') or
+        document.querySelector('modal-in')
       return if not event.altKey and
         %w(input textarea).include? document.activeElement.tagName.downcase()
       return if event.metaKey or event.ctrlKey
 
       if event.keyCode == 37 # '<-'
-        link = ~"a[rel=prev]"[0]
+        link = document.querySelector("a[rel=prev]")
         if link
           link.click()
           return false
         end
       elsif event.keyCode == 39 # '->'
-        link = ~"a[rel=next]"[0]
+        link = document.querySelector("a[rel=next]")
         if link
           link.click()
           return false
diff --git a/www/board/agenda/views/layout/footer.js.rb b/www/board/agenda/views/layout/footer.js.rb
index 3a8d73a..1a8114b 100644
--- a/www/board/agenda/views/layout/footer.js.rb
+++ b/www/board/agenda/views/layout/footer.js.rb
@@ -8,7 +8,7 @@
 #  last flagged <-> first Special order)
 #
 
-class Footer < React
+class Footer < Vue
   def render
     _footer.navbar.navbar_fixed_bottom class: @@item.color do
 
@@ -70,10 +70,17 @@
       _span do
         if @@buttons
           @@buttons.each do |button|
+
             if button.text
-              React.createElement('button', button.attrs, button.text)
+              props = {attrs: button.attrs}
+              if button.attrs.class
+                props.class = button.attrs.class.split(' ')
+                delete button.attrs.class
+              end
+
+              Vue.createElement('button', props, button.text)
             elsif button.type
-              React.createElement(button.type, button.attrs)
+              Vue.createElement(button.type, {props: button.attrs})
             end
           end
         end
diff --git a/www/board/agenda/views/layout/header.js.rb b/www/board/agenda/views/layout/header.js.rb
index 016906d..5ba3590 100644
--- a/www/board/agenda/views/layout/header.js.rb
+++ b/www/board/agenda/views/layout/header.js.rb
@@ -5,7 +5,7 @@
 #
 # Finally: make info dropdown status 'sticky'
 
-class Header < React
+class Header < Vue
   def initialize
     @infodropdown = nil
   end
@@ -118,14 +118,9 @@
     end
   end
 
-  # set title on initial rendering
-  def componentDidMount()
-    self.componentDidUpdate()
-  end
-
   # update title to match the item title whenever page changes
-  def componentDidUpdate()
-    title = ~'title'
+  def mounted()
+    title = document.getElementsByTagName('title')[0]
     if title.textContent != @@item.title
       title.textContent = @@item.title
     end
diff --git a/www/board/agenda/views/layout/main.js.rb b/www/board/agenda/views/layout/main.js.rb
index 607895f..a2de545 100644
--- a/www/board/agenda/views/layout/main.js.rb
+++ b/www/board/agenda/views/layout/main.js.rb
@@ -8,7 +8,7 @@
 #  * Resizing view to leave room for the Header and Footer
 #
 
-class Main < React
+class Main < Vue
   # common layout for all pages: header, main, footer, and forms
   def render
     if not @item
@@ -19,8 +19,8 @@
 
       view = nil
       _main do
-        React.createElement(@item.view, item: @item,
-         ref: proc {|component| Main.view=component})
+        Vue.createElement(@item.view, props: {item: @item,
+         ref: proc {|component| Main.view=component}})
       end
 
       _Footer item: @item, buttons: @buttons, options: @options
@@ -29,8 +29,8 @@
       if @buttons
         @buttons.each do |button|
           if button.form
-            React.createElement(button.form, item: @item, server: Server,
-              button: button)
+            Vue.createElement(button.form, props: {item: @item, server: Server,
+              button: button})
           end
         end
       end
@@ -38,7 +38,7 @@
   end
 
   # initial load of the agenda, and route first request
-  def componentWillMount()
+  def created()
     # copy server info for later use
     for prop in @@server
       Server[prop] = @@server[prop]
@@ -85,7 +85,7 @@
   end
 
   # additional client side initialization
-  def componentDidMount()
+  def mounted()
     # export navigate and refresh methods
     Main.navigate = self.navigate
     Main.refresh  = self.refresh
@@ -118,7 +118,7 @@
     # whenever the window is resized, adjust margins of the main area to
     # avoid overlapping the header and footer areas
     def window.onresize()
-      main = ~'main'
+      main = document.querySelector('main')
       if 
         window.innerHeight <= 400 and 
         document.body.scrollHeight > window.innerHeight
@@ -130,8 +130,10 @@
       else
         document.querySelector('footer').style.position = 'fixed'
         document.querySelector('header').style.position = 'fixed'
-        main.style.marginTop = "#{~'header.navbar'.clientHeight}px"
-        main.style.marginBottom = "#{~'footer.navbar'.clientHeight}px"
+        main.style.marginTop = 
+          "#{document.querySelector('header.navbar').clientHeight}px"
+        main.style.marginBottom = 
+          "#{document.querySelector('footer.navbar').clientHeight}px"
       end
 
       if Main.scrollTo == 0 or Main.scrollTo
@@ -160,7 +162,7 @@
   end
 
   # after each subsequent re-rendering, resize main window
-  def componentDidUpdate()
+  def updated()
     window.onresize()
   end
 end
diff --git a/www/board/agenda/views/pages/action-items.js.rb b/www/board/agenda/views/pages/action-items.js.rb
index e83fb32..a14d96c 100644
--- a/www/board/agenda/views/pages/action-items.js.rb
+++ b/www/board/agenda/views/pages/action-items.js.rb
@@ -3,7 +3,7 @@
 # action item status updates.
 #
 
-class ActionItems < React
+class ActionItems < Vue
   def initialize
     @disabled = false
   end
@@ -73,19 +73,20 @@
           end
 
           # launch edit dialog when there is a click on the status
-          attrs = {onClick: self.updateStatus, className: 'clickable'}
-          attrs = {} if Minutes.complete
+          options = {on: {click: self.updateStatus}, class: ['clickable']}
+          options = {} if Minutes.complete
+          options.attrs = {}
 
           # copy action properties to data attributes
           for name in action
-            attrs["data-#{name}"] = action[name]
+            options.attrs["data-#{name}"] = action[name]
           end
 
           # include pending updates
           pending = Pending.find_status(action)
-          attrs['data-status'] = pending.status if pending
+          options.attrs['data-status'] = pending.status if pending
 
-          React.createElement('span', attrs) do
+          Vue.createElement('span', options) do
             # highlight missing action item status updates
             if pending
               _span "Status: "
@@ -175,7 +176,7 @@
   end
 
   # autofocus on action status in update action form
-  def componentDidMount()
+  def mounted()
     jQuery('#updateStatusForm').on 'shown.bs.modal' do
       ~statusText.focus()
     end
diff --git a/www/board/agenda/views/pages/adjournment.js.rb b/www/board/agenda/views/pages/adjournment.js.rb
index dc41fd3..2dc8ea1 100644
--- a/www/board/agenda/views/pages/adjournment.js.rb
+++ b/www/board/agenda/views/pages/adjournment.js.rb
@@ -2,17 +2,40 @@
 # Secretary version of Adjournment section: shows todos
 #
 
-class Adjournment < React
+class Adjournment < Vue
   def initialize
-    Todos.set({
-      add: [],
-      remove: [],
-      establish: [],
-      feedback: [],
-      minutes: {},
-      loading: true,
-      fetched: false
-    })
+    @add = []
+    @remove = []
+    @establish = []
+    @feedback = []
+    @minutes = {}
+    @loading = true
+    @fetched = false
+  end
+
+  # export self as shared state
+  def created()
+    if defined? global
+      global.Todos = self
+    else
+      window.Todos = self
+    end
+  end
+
+  # update state
+  def set(value)
+    for attr in value
+      Todos[attr] = value[attr]
+    end
+  end
+
+  # find corresponding agenda item
+  def link(title)
+    link = nil
+    Agenda.index.each do |item|
+      link = item.href if item.title == title
+    end
+    return link
   end
 
   def render
@@ -102,13 +125,8 @@
     end
   end
 
-  # check for minutes being completed on first load
-  def componentDidMount()
-    self.componentDidUpdate()
-  end
-
   # fetch secretary todos once the minutes are complete
-  def componentDidUpdate()
+  def mounted()
     if Minutes.complete and Todos.loading and not Todos.fetched
       Todos.fetched = true
       retrieve "secretary-todos/#{Agenda.title}", :json do |todos|
@@ -123,20 +141,15 @@
 #                          Add, Remove chairs                          #
 ########################################################################
 
-class TodoActions < React
+class TodoActions < Vue
   def initialize
     @checked = {}
     @disabled = true
     @people = []
   end
 
-  # check for minutes being completed on first load
-  def componentDidMount()
-    self.componentWillReceiveProps()
-  end
-
   # update check marks based on current Todo list
-  def componentWillReceiveProps()
+  def created()
     @people = Todos[@@action]
 
     # uncheck people who were removed
@@ -166,7 +179,7 @@
     end
     @disabled = disabled
 
-    self.forceUpdate()
+    Vue.forceUpdate()
   end
 
   def render
@@ -219,20 +232,15 @@
 #                          Establish actions                           #
 ########################################################################
 
-class EstablishActions < React
+class EstablishActions < Vue
   def initialize
     @checked = {}
     @disabled = true
     @podlings = []
   end
 
-  # check for minutes being completed on first load
-  def componentDidMount()
-    self.componentWillReceiveProps()
-  end
-
   # update check marks based on current Todo list
-  def componentWillReceiveProps()
+  def created()
     @podlings = Todos.establish
 
     # uncheck podlings that were removed
@@ -262,7 +270,7 @@
     end
     @disabled = disabled
 
-    self.forceUpdate()
+    Vue.forceUpdate()
   end
 
   def render
@@ -308,7 +316,7 @@
 #                      Reminder to draft feedback                      #
 ########################################################################
 
-class FeedbackReminder < React
+class FeedbackReminder < Vue
   def render
     _p 'Draft feedback:'
 
@@ -322,24 +330,3 @@
       onClick:-> {window.location.href = 'feedback'}
   end
 end
-
-########################################################################
-#                             shared state                             #
-########################################################################
-
-class Todos
-  def self.set(value)
-    for attr in value
-      Todos[attr] = value[attr]
-    end
-  end
-
-  # find corresponding agenda item
-  def self.link(title)
-    link = nil
-    Agenda.index.each do |item|
-      link = item.href if item.title == title
-    end
-    return link
-  end
-end
diff --git a/www/board/agenda/views/pages/backchannel.js.rb b/www/board/agenda/views/pages/backchannel.js.rb
index 47763b1..805c3f4 100644
--- a/www/board/agenda/views/pages/backchannel.js.rb
+++ b/www/board/agenda/views/pages/backchannel.js.rb
@@ -2,7 +2,7 @@
 # Overall Agenda page: simple table with one row for each item in the index
 #
 
-class Backchannel < React
+class Backchannel < Vue
   # place a message input field in the buttons area
   def self.buttons()
     return [{button: Message}]
@@ -62,13 +62,13 @@
   end
 
   # on initial display, fetch backlog
-  def componentDidMount()
+  def mounted()
     Main.scrollTo = -1
     Chat.fetch_backlog()
   end
 
   # if we are at the bottom of the page, keep it that way
-  def componentWillUpdate()
+  def beforeUpdate()
     if 
       window.pageYOffset + window.innerHeight >=
       document.documentElement.scrollHeight
diff --git a/www/board/agenda/views/pages/bootstrap.js.rb b/www/board/agenda/views/pages/bootstrap.js.rb
index 202c0e9..7a1a578 100644
--- a/www/board/agenda/views/pages/bootstrap.js.rb
+++ b/www/board/agenda/views/pages/bootstrap.js.rb
@@ -2,7 +2,7 @@
 # Blank canvas shown during bootstrapping
 #
 
-class BootStrapPage < React
+class BootStrapPage < Vue
   def render
     _p ''
   end
diff --git a/www/board/agenda/views/pages/cache.js.rb b/www/board/agenda/views/pages/cache.js.rb
index 8f04b7b..928357b 100644
--- a/www/board/agenda/views/pages/cache.js.rb
+++ b/www/board/agenda/views/pages/cache.js.rb
@@ -2,7 +2,7 @@
 # A page showing status of caches and service workers
 #
 
-class CacheStatus < React
+class CacheStatus < Vue
   def self.buttons()
     return [{button: ClearCache}, {button: UnregisterWorker}]
   end
@@ -66,13 +66,8 @@
 
   end
 
-  # update on first update
-  def componentDidMount()
-    self.componentWillReceiveProps()
-  end
-
   # update caches
-  def componentWillReceiveProps()
+  def created()
     if defined? caches
       caches.open('board/agenda').then do |cache|
         cache.matchAll().then do |responses|
@@ -92,7 +87,7 @@
 #
 # A button that clear the cache
 #
-class ClearCache < React
+class ClearCache < Vue
   def initialize
     @disabled = true
   end
@@ -102,13 +97,8 @@
       disabled: @disabled
   end 
 
-  # update on first update
-  def componentDidMount()
-    self.componentWillReceiveProps()
-  end
-
   # enable button if there is anything in the cache
-  def componentWillReceiveProps()
+  def created()
     if defined? caches
       caches.open('board/agenda').then do |cache|
         cache.matchAll().then do |responses|
@@ -131,7 +121,7 @@
 # A button that removes the service worker.  Sadly, it doesn't seem to have
 # any affect on the list of registrations that is dynamically returned.
 #
-class UnregisterWorker < React
+class UnregisterWorker < Vue
   def render
     _button.btn.btn_primary 'Unregister ServiceWorker', onClick: self.click
   end 
@@ -156,7 +146,7 @@
 # Individual Cache page
 #
 
-class CachePage < React
+class CachePage < Vue
   def initialize
     @response = {}
     @text = ''
@@ -189,7 +179,7 @@
   end
 
   # update on first update
-  def componentDidMount()
+  def mounted()
     if defined? caches
       basename = location.href.split('/').pop()
       basename = '' if basename == 'index.html'
diff --git a/www/board/agenda/views/pages/comments.js.rb b/www/board/agenda/views/pages/comments.js.rb
index a7d8b26..282932d 100644
--- a/www/board/agenda/views/pages/comments.js.rb
+++ b/www/board/agenda/views/pages/comments.js.rb
@@ -3,7 +3,7 @@
 # Conditionally hide comments previously marked as seen.
 #
 
-class Comments < React
+class Comments < Vue
   def self.buttons()
     buttons = []
 
@@ -29,10 +29,6 @@
     @showseen = ! @showseen
   end
 
-  def showseen
-    return @showseen
-  end
-
   def render
     found = false
 
diff --git a/www/board/agenda/views/pages/flagged.js.rb b/www/board/agenda/views/pages/flagged.js.rb
index 9afbb99..a5cf73d 100644
--- a/www/board/agenda/views/pages/flagged.js.rb
+++ b/www/board/agenda/views/pages/flagged.js.rb
@@ -2,7 +2,7 @@
 # A page showing all flagged reports
 #
 
-class Flagged < React
+class Flagged < Vue
   def render
     first = true
 
diff --git a/www/board/agenda/views/pages/fy22.js.rb b/www/board/agenda/views/pages/fy22.js.rb
index b5e7023..e246b05 100644
--- a/www/board/agenda/views/pages/fy22.js.rb
+++ b/www/board/agenda/views/pages/fy22.js.rb
@@ -1,7 +1,7 @@
 #
 # FY22 budget worksheet
 #
-class FY22 < React
+class FY22 < Vue
   def initialize
     @budget = (Minutes.started && Minutes.get('budget')) || {
       donations: 110,
@@ -323,11 +323,11 @@
       end
     end
 
-    self.forceUpdate()
+    Vue.forceUpdate()
   end
 
   # receive updated budget values
-  def componentWillReceiveProps()
+  def created()
     budget = Minutes.get('budget')
 
     if budget and budget != @budget and Minutes.started
diff --git a/www/board/agenda/views/pages/help.js.rb b/www/board/agenda/views/pages/help.js.rb
index dddfef9..091dddd 100644
--- a/www/board/agenda/views/pages/help.js.rb
+++ b/www/board/agenda/views/pages/help.js.rb
@@ -1,4 +1,4 @@
-class Help < React
+class Help < Vue
   def render
     _h3 'Keyboard shortcuts'
     _dl.dl_horizontal do
diff --git a/www/board/agenda/views/pages/index.js.rb b/www/board/agenda/views/pages/index.js.rb
index 58424a8..7fbed65 100644
--- a/www/board/agenda/views/pages/index.js.rb
+++ b/www/board/agenda/views/pages/index.js.rb
@@ -2,7 +2,7 @@
 # Overall Agenda page: simple table with one row for each item in the index
 #
 
-class Index < React
+class Index < Vue
   def render
     _header do
       _h1 'ASF Board Agenda'
diff --git a/www/board/agenda/views/pages/missing.js.rb b/www/board/agenda/views/pages/missing.js.rb
index db407bb..d5b1a3f 100644
--- a/www/board/agenda/views/pages/missing.js.rb
+++ b/www/board/agenda/views/pages/missing.js.rb
@@ -2,18 +2,13 @@
 # A page showing all flagged reports
 #
 
-class Missing < React
+class Missing < Vue
   def initialize
     @checked = {}
   end
 
-  # update checkmarks on first load
-  def componentDidMount()
-    self.componentWillReceiveProps()
-  end
-
   # update check marks based on current Index
-  def componentWillReceiveProps()
+  def mounted()
     Agenda.index.each do |item|
       @checked[item.title] = true unless defined? @checked[item.title]
     end
@@ -29,7 +24,7 @@
             _input type: 'checkbox', name: 'selected', value: item.title,
               checked: @checked[item.title], onChange:-> {
                 @checked[item.title] = !@checked[item.title]
-                self.forceUpdate()
+                Vue.forceUpdate()
               }
           end
 
diff --git a/www/board/agenda/views/pages/queue.js.rb b/www/board/agenda/views/pages/queue.js.rb
index df10e3f..c8903ff 100644
--- a/www/board/agenda/views/pages/queue.js.rb
+++ b/www/board/agenda/views/pages/queue.js.rb
@@ -3,7 +3,7 @@
 # that are ready for review.
 #
 
-class Queue < React
+class Queue < Vue
   def self.buttons()
     buttons = [{button: Refresh}]
     buttons << {form: Commit} if Pending.count > 0
@@ -26,7 +26,7 @@
 
         # Unapproved
         %w(Unapprovals Flagged Unflagged).each do |section|
-          list = self.state[section.downcase()]
+          list = $data[section.downcase()]
           unless list.empty?
             _h4 section
             _p.col_xs_12 do
@@ -88,13 +88,9 @@
     end
   end
 
-  # set state on first load
-  def componentWillMount()
-    self.componentWillReceiveProps()
-  end
-
   # determine approvals, rejected, comments, and ready
-  def componentWillReceiveProps()
+  def created()
+  console.log('created')
     @approvals = []
     @unapprovals = []
     @flagged = []
diff --git a/www/board/agenda/views/pages/report.js.rb b/www/board/agenda/views/pages/report.js.rb
index 89e5d48..4959a93 100644
--- a/www/board/agenda/views/pages/report.js.rb
+++ b/www/board/agenda/views/pages/report.js.rb
@@ -12,7 +12,7 @@
 # Filters may be used to highlight or hypertext link portions of the text.
 #
 
-class Report < React
+class Report < Vue
   def render
     _section.flexbox do
       _section do
@@ -56,12 +56,7 @@
     end
   end
 
-  # check for additional actions on initial render
-  def componentWillMount()
-    self.componentWillReceiveProps()
-  end
-
-  def componentWillReceiveProps()
+  def created()
     # determine what text filters to run
     @filters = [self.linebreak, self.todo, hotlink, self.privates, self.jira]
     @filters = [self.localtime, hotlink] if @@item.title == 'Call to order'
diff --git a/www/board/agenda/views/pages/roll-call.js.rb b/www/board/agenda/views/pages/roll-call.js.rb
index 85458b8..1b5153a 100644
--- a/www/board/agenda/views/pages/roll-call.js.rb
+++ b/www/board/agenda/views/pages/roll-call.js.rb
@@ -1,7 +1,7 @@
 #
 # Secretary Roll Call update form
 
-class RollCall < React
+class RollCall < Vue
   def initialize
     RollCall.lockFocus = false
     @guest = ''
@@ -67,13 +67,8 @@
     end
   end
 
-  # perform initialization on first rendering
-  def componentWillMount()
-    self.componentWillReceiveProps()
-  end
-
   # collect a sorted list of people
-  def componentWillReceiveProps()
+  def created()
     people = []
 
     # start with those listed in the agenda
@@ -108,7 +103,7 @@
   end
 
   # client side initialization on first rendering
-  def componentDidMount()
+  def mounted()
     if Server.committers
       @disabled = false
     else
@@ -124,11 +119,11 @@
   end
 
   # scroll walkon input field towards the center of the screen
-  def componentDidUpdate()
+  def updated()
     if RollCall.lockFocus and @guest.length >= 3
-      walkon = ~'.walkon'
+      walkon = document.getElementsByClassName("walkon")[0]
       offset = walkon.offsetTop + walkon.offsetHeight/2 - window.innerHeight/2
-      jQuery('html, body').animate({scrollTop: offset}, :slow);
+      jQuery('html, body').animate({scrollTop: offset}, :slow)
     end
   end
 end
@@ -136,25 +131,20 @@
 #
 # An individual attendee (Director, Executive Officer, or Guest)
 #
-class Attendee < React
+class Attendee < Vue
   def initialize
     # last posted value for notes for this attendee
     @base = ''
   end
 
   # perform initialization on first rendering
-  def componentWillMount()
-    self.componentWillReceiveProps()
-  end
-
-  # whenever person changes, reflect current status
-  def componentWillReceiveProps()
+  def created()
     status = Minutes.attendees[@@person.name]
     if status
       @checked = status.present
       @notes = (status.notes ? status.notes.sub(' - ', '') : '')
     else
-      @checked = ''
+      @checked = false
       @notes = ''
     end
   end
@@ -164,7 +154,7 @@
   # forms.  CSS controls which version of the notes is actually displayed.
   def render
     _li onMouseOver: self.focus do
-      _input type: :checkbox, checked: @checked, onChange: self.click
+      _input type: :checkbox, checked: @checked, onClick: self.click
 
       roster = '/roster/committer/'
       if @@person.id
@@ -195,7 +185,7 @@
   end
 
   # initialize pending update status
-  def componentDidMount()
+  def mounted()
     self.pending = false
   end
 
@@ -214,7 +204,7 @@
   end
 
   # after display is updated, send any pending updates to the server
-  def componentDidUpdate()
+  def updated()
     return unless self.pending
 
     data = {
diff --git a/www/board/agenda/views/pages/search.js.rb b/www/board/agenda/views/pages/search.js.rb
index 2c1a9bf..aba2a82 100644
--- a/www/board/agenda/views/pages/search.js.rb
+++ b/www/board/agenda/views/pages/search.js.rb
@@ -5,7 +5,7 @@
 #  * keep query string in window location URL in synch
 #
 
-class Search < React
+class Search < Vue
   # initialize query text based on data passed to the component
   def initialize
     @text = @@item.query || ''
@@ -33,10 +33,9 @@
           # highlight matching strings in paragraph
           item.text.split(/\n\s*\n/).each do |paragraph|
             if paragraph.downcase().include? text
-              _pre.report dangerouslySetInnerHTML: {
-                __html: htmlEscape(paragraph).gsub(/(#{text})/i,
+              _pre.report domPropsInnerHTML:
+                htmlEscape(paragraph).gsub(/(#{text})/i,
                  "<span class='hilite'>$1</span>")
-              }
             end
           end
         end
@@ -58,12 +57,16 @@
   end
 
   # set history on initial rendering
-  def componentDidMount()
-    self.componentDidUpdate()
+  def mounted()
+    self.updateHistory()
   end
 
   # replace history state on subsequent renderings
-  def componentDidUpdate()
+  def updated()
+    self.updateHistory()
+  end
+
+  def updateHistory()
     state = {path: 'search', query: @text}
 
     if state.query
diff --git a/www/board/agenda/views/pages/select-actions.rb b/www/board/agenda/views/pages/select-actions.rb
index 85f8223..432fc3a 100644
--- a/www/board/agenda/views/pages/select-actions.rb
+++ b/www/board/agenda/views/pages/select-actions.rb
@@ -3,7 +3,7 @@
 # action item status updates.
 #
 
-class SelectActions < React
+class SelectActions < Vue
   def self.buttons()
     return [{button: PostActions}]
   end
@@ -27,7 +27,7 @@
     end
   end
 
-  def componentDidMount()
+  def mounted()
     retrieve 'potential-actions', :json do |response|
       if response
         SelectActions.list = response.actions
@@ -37,10 +37,10 @@
   end
 end
 
-class CandidateAction < React
+class CandidateAction < Vue
   def render
     _input type: 'checkbox', checked: !@@action.complete,
-      onChange:-> {@@action.complete = !@@action.complete; self.forceUpdate()}
+      onChange:-> {@@action.complete = !@@action.complete; Vue.forceUpdate()}
     _span " "
     _span @@action.owner
     _span ": "
diff --git a/www/board/agenda/views/pages/shepherd.js.rb b/www/board/agenda/views/pages/shepherd.js.rb
index 54ae353..5cd5a26 100644
--- a/www/board/agenda/views/pages/shepherd.js.rb
+++ b/www/board/agenda/views/pages/shepherd.js.rb
@@ -3,7 +3,7 @@
 # that are ready for review.
 #
 
-class Shepherd < React
+class Shepherd < Vue
   def initialize
     @disabled = false
     @followup = []
@@ -70,7 +70,7 @@
   end
 
   # Fetch followup items
-  def componentDidMount()
+  def mounted()
     # if cached, reuse
     if Shepherd.followup
       @followup = Shepherd.followup
diff --git a/www/board/agenda/views/react/scaffold.html.erb b/www/board/agenda/views/react/scaffold.html.erb
new file mode 100644
index 0000000..1315f94
--- /dev/null
+++ b/www/board/agenda/views/react/scaffold.html.erb
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+  <head>
+    <meta charset="utf-8"/>
+    <title>ASF Board Agenda</title>
+    <base href="<%= @base %>"/>
+    <link rel="stylesheet" type="text/css" href="/assets/bootstrap-min.css"/>
+    <link rel="stylesheet" type="text/css" href="/assets/bootstrap-theme.min.css"/>
+    <link rel="stylesheet" href="../../stylesheets/app.css?<%= @cssmtime %>"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+  </head>
+
+  <body>
+    <div id="main"></div>
+
+    <script src="/assets/react-min.js"></script>
+    <script src="/assets/react-dom.min.js"></script>
+    <script src="/assets/jquery-min.js"></script>
+    <script src="/assets/bootstrap-min.js"></script>
+    <script src="../app.js?<%= @appmtime %>" lang="text/javascript"></script>
+    <script>//<![CDATA[
+      ReactDOM.render(React.createElement(Main,
+        <%= JSON.generate server: @server, page: @page %>),
+        document.getElementById("main"))
+    //]]></script>
+  </body>
+</html>
diff --git a/www/board/agenda/views/router.js.rb b/www/board/agenda/views/router.js.rb
index e446222..c4e9aaa 100644
--- a/www/board/agenda/views/router.js.rb
+++ b/www/board/agenda/views/router.js.rb
@@ -110,7 +110,7 @@
     buttons = item.view.buttons().concat(buttons || []) if item.view.buttons
     if buttons
       buttons = buttons.map do |button|
-        props = {text: 'button', attrs: {className: 'btn'}, form: button.form}
+        props = {text: 'button', attrs: {class: 'btn'}, form: button.form}
 
         # form overrides
         form = button.form
@@ -119,7 +119,7 @@
             if name == 'text'
               props.text = form.button.text
             elsif name == 'class' or name == 'classname'
-              props.attrs.className += " #{form.button[name].gsub('_', '-')}"
+              props.attrs.class += " #{form.button[name].gsub('_', '-')}"
             else
               props.attrs[name.gsub('_', '-')] = form.button[name]
             end
@@ -136,7 +136,7 @@
           if name == 'text'
             props.text = button.text
           elsif name == 'class' or name == 'classname'
-            props.attrs.className += " #{button[name].gsub('_', '-')}"
+            props.attrs.class += " #{button[name].gsub('_', '-')}"
           elsif name != 'form'
             props.attrs[name.gsub('_', '-')] = button[name]
           end
diff --git a/www/board/agenda/views/vue-config.js.rb b/www/board/agenda/views/vue-config.js.rb
new file mode 100644
index 0000000..bdcce94
--- /dev/null
+++ b/www/board/agenda/views/vue-config.js.rb
@@ -0,0 +1,8 @@
+# Filter out "data property already declared as a prop" warnings
+Vue.config.warnHandler = proc do |msg, vm, trace|
+  return if msg =~ /^The data property "\w+" is already declared as a prop\./
+  console.error "[Vue warn]: " + msg + trace if defined? console
+end
+
+# reraise errors to enable easier debugging
+Vue.config.errorHandler = proc {|err, vm, info| raise err}