ASP.NET MVC Optimization Series: Part 6 - Building Our Agenda Screen

July 27th, 2015

In our final post for this series, we show how to select specific sessions using native UI techniques through simple HTML5 and a JavaScript database called PouchDb.

In the past series, we explained how to take a regular web application and turn it into a native-like application.

To finish off the optimization series, we'll talk about how to take that data and build a simple agenda based on the list of sessions. We will also focus on pouchdb, a JavaScript database which I dug into when introduced to it at Codemash this year.

Our development today will primarily focus on JavaScript/PouchDb and HTML with a touch of MVC for our agenda page.

Since have all of our lists defined (speakers and sessions) from our past posts (1, 2, 3, 4, and 5), we can get the simple stuff out of the way.

Simple Preparations For Our Agenda

Here are the simple updates to the project:

So once we have everything in place, we can focus on our design of building our agenda.

Agenda Requirements

Our application wouldn't be much if we couldn't pick out our sessions we wanted to attend.

We need a way to pick our sessions (ad-hoc/at-will) and add them to our agenda. The user should be able to do that on any session detail page.

After reading the description, they can choose whether they want to attend it or not by pushing the favorite button.

So let's start looking at the _partialSession.cshtml partial code.

Views\Shared\_partialSession.cshtml

@model CodemashApp.ViewModel.SessionDetailViewModel
 
<a href="javascript:void(0);" class="back-button"><i class="fa fa-angle-double-left"></i> Back</a>
<div class="list-group-item session-info" data-id="@Model.Session.Id">
    <h4 class="list-group-item-heading">@Model.Title</h4>
    <ul class="list-inline list-unstyled small">
        <li>
            <i class="fa fa-user fa-fw"></i>
            @Model.Session.Speakers.Select(i =>
                i.FirstName + " " + i.LastName).Aggregate((i, j) => i + ", " + j)
        </li>
        <li>
            <i class="fa fa-calendar fa-fw"></i>
            @Model.Session.StartDate
            from
            @Model.Session.StartTime - @Model.Session.EndTime
        </li>
        <li>
            <i class="fa fa-map-marker fa-fw"></i> @Model.Session.Rooms.Aggregate((i, j) => i + ", " + j)
        </li>
        <li></li>
    </ul>
 
    <p class="list-group-item-text">@Html.Raw(Model.Session.Abstract.Replace("\r\n","<br/>"))</p>
    <p>&nbsp;</p>
    <p><button class="btn btn-default btn-sm btn-info"><i class="fa fa-heart"></i> Favorite</button></p>
</div>

Everything is pretty much the same except for the changes (which is indicated with bold).

We added a button called Favorite and added an icon with a heart next to it. When immediately viewing the session detail, they will see a favorite button with no heart. When they choose it make it their favorite, they see a heart appear.  

But how do we save our choice to our agenda?

Crash Course on PouchDb

This is where we start discussing pouchdb. Pouchdb is a client-side database written entirely in JavaScript and uses HTML5 local storage with a number of other useful features, like synchronization with versioning. Very cool!

If you want to see code regarding pouchdb, I would recommend their API section on their site.

Once we create our database, it won't matter what page you're on, you will still have access to your data.

This also brings up a good point. PouchDb is based off of CouchDb and is not a relational database, it's more of a document database or a hierarchical database. Think of it as cookies...on steroids...on every page...to persist your clients' preferences. :-)

For starters, we need to create our database. At the top of our codemash.js, we define our pouchDb database.

$(function() {
    var db = new PouchDB('codemashdb');

I know what you're thinking. "Every time we visit this site, it will erase my database."

No...no it won't. It will check to see if you have that database available. If it's not there, it will create a new one. If it already exists, we just start using it.

One other note: PouchDb is a little different because it's an asynchronous database. When you check to see if there is a "document" available, it could return an error or a document.

At this point, we are ready to start using our "database." Here is what we have so far for our click event on our button.

var sessionInfo = $(".session-info");
var id = $(sessionInfo).attr("data-id");
$("button", sessionInfo).on("click"function (t) {
    t.preventDefault();
    var button = $(this);
    db.get(id, {}, function(error, doc) {
        if (error) {
            if (error.status === 404) {
                // Add a new doc.
                $("i", button).addClass("fa-heart");
                $(sessionInfo).attr("data-selected""true");
                return db.put({
                    _id: id
                });
            } else {
                // some other error.
                console.write(error);
            }
            return false;
        } else {
            // Remove it.
            return db.remove(doc, function() {
                $("i", button).removeClass("fa-heart");
                $(sessionDetail).attr("data-selected""false");
            });
        }
    });
    return false;
});

Let's go through this.

In the button click, we immediately stop the default behavior of the button and make a reference to the button.

The next line signifies PouchDb retrieving a "document." PouchDb uses a reserve variable called _id for all of its documents in the database. Every time you create a new document, you can assign that id. In this case, it's extremely easy because we have session ids from our web service.

So the variable "id" in the get method is pulled from the HTML when we rendered each session giving us the session number (look above in the _partialSession.cshtml with the data-id attribute).

Remember I said that pouchDb was asynchronous-based. It uses JavaScript promises instead of callbacks, but old habits die hard, so I'm using callbacks (but I can change...really!)

After we request the document through our db.get(), we define the callback and act on the results returned. However, we may have an error.

Why, you ask?

If we just created the database on an initial run, we won't have any documents in the database so we need to check for a 404 meaning the document was not found. Clever, huh?

If it wasn't found, we need to "put" the document into the database and we notify the user with a favorite (heart). To insert a record into the table, we use db.put() with a JSON object.

As mentioned above, we need an id...and we have one. So we've saved the record.

If you need to view your database at anytime, Google Chrome's DevTools have an IndexedDB area under resources for you to view your records and structure.

Ok, so what happens when we don't get an error? We actually get a document back.

Since our button is a toggle, we could do one of two things here:

  1. Update the record with a "selected" field that's either true or false, or
  2. Delete the record altogether meaning that they don't like it as their favorite anymore.

The downside to number one is that we could get a LOT of records in the table that aren't being used. The lesser the records, the better. So I went with number two. Remember, we need speed where possible.

The db.remove() takes the document and the callback when finished. For the button, we remove the heart (mother always did say I should be a surgeon). ;-)

"Back, I Say!"

What happens when we hit the Back link at the top of each session detail? We want our heart to appear on our session list.

Our _sessionItem.cshtml has a simple heart floating to the right to indicate a favorited session (indicated in bold below).

Views\Shared\_SessionItem.cshtml

@using CodemashApp.Helpers.Url
@model CodemashApp.Models.Session
 
<li>
    <a name="@Model.Id" href="javascript:void(0);" class="session list-group-item" 
       data-target="@Url.SessionDetailUrl(Model.Id)">
        <h4 class="list-group-item-heading">@Model.Title</h4>
        <i class="favorite fa fa-heart fa-2x pull-right"></i>
        <ul class="list-inline list-unstyled small">
            <li>
                <i class="fa fa-user fa-fw"></i>
                @Model.Speakers.Select(i =>
                        i.FirstName + " " + i.LastName).Aggregate((i, j) => i + ", " + j)
            </li>
            <li>
                <i class="fa fa-calendar fa-fw"></i>
                @Model.StartDate
                from
                @Model.StartTime - @Model.EndTime
            </li>
            <li>
                <i class="fa fa-map-marker fa-fw"></i>
                    @Model.Rooms.Aggregate((i, j) => i + ", " + j)
            </li>
        </ul>
 
 
    </a>
</li>

In our JavaScript Click Handler for our Back link, during our transition back to the session list, we locate the session number by using the name (called "jumper") and immediately position the window to the top with our session we selected.

We also get the document to make sure it's favorited or not. If it is, we show the heart. If the document isn't in the database, we turn off the heart.

$(".back-button").on("click"function(f) {
    f.preventDefault();
    $(sessionDetail).fadeOut("fast"function() {
        $(".panel-group").fadeIn("fast");
        var item = $("[name='" + jumper + "']");
        $(document).scrollTop(item.offset().top - 50);
        db.get(jumper, {}, function(error, response) {
            if (error == null) {
                $(".favorite", item).show();
            } else {
                if (error.status === 404) {
                    $(".favorite", item).hide();
                } else {
                    $(".favorite", item).show();
                }
            }
        });
 
    });
});

Fixing up our Sessions List on Initial Display

We forgot something. When we visit a sessions page whether it's by full session list or the session list by day, we need a way to show those favorite sessions when initially loading the page.

PouchDb has a method for batch loading all of your documents called allDocs(). Since this database should be small enough, we'll load all of the documents from the database. 

// determine if we are on the sessions page (either all or by day).
if ($("a.session").size() > 0) {
    $("a.session").on("click", sessionClickEvent);
 
    db.allDocs().then(function(result) {
        var rows = result.rows;
        $(rows).each(function(index, elem) {
            if (!elem._deleted) {
                $(".favorite""a[name=" + elem.id + "]").show();
            }
        });
    });
}

I decided to use a JavaScript promise here instead of a callback. Once we receive our results, we grab the rows and iterate through each item.

Since the session id is attached to the row number ("<tr data-id='2601'>"), we can easily find the favorite in each row and display it.

PouchDb also has a property for deleted rows.

What's on the Agenda now?

The agenda page requires the data to be grouped and sorted by day, but everything is happening on the client-side. When we receive our data from the server, we get all of the records for the days.

Immediately, we can hide the records and show only the ones from our client-side favorites.

Here is what our Agenda page looks like.

Views\Codemash\Agenda.cshtml

@using CodemashApp.Models
@model CodemashApp.ViewModel.CodemashAgendaViewModel
@{
    Layout = "~/Views/Shared/_Layout.cshtml";
}
<div class="agenda">
    <div class="table-responsive">
        <table class="table table-condensed table-bordered">
            <tbody>
                <tr>
                    <td class="agenda-date" colspan="2">
                        My Codemash Agenda
                    </td>
                </tr>
                @foreach (IGrouping<DateTime, Session> sessionTime in Model.GroupedSessions)
                {
                    <tr>
                        <td class="agenda-date navbar" colspan="2">
                            <div class="dayofmonth">@sessionTime.Key.Day</div>
                            <div class="agenda-day-month">
                                <div class="dayofweek">@sessionTime.Key.DayOfWeek</div>
                                <div class="shortdate text-muted">
                                    @sessionTime.Key.ToString("MMM"), @sessionTime.Key.Year
                                </div>
                            </div>
                            <div class="agenda-time">@sessionTime.Key.ToShortTimeString()</div>
                        </td>
                    </tr>
                    foreach (var session in sessionTime.ToList()
                        .OrderBy(e => e.SessionStartTime)
                        .ThenBy(e => e.Title))
                    {
                        <tr data-id="@session.Id">
                            <td class="agenda-select"><i class="fa fa-2x fa-check-square-o"></i></td>
                            <td class="agenda-events">
                                <div class="agenda-event">
                                    @session.Title
                                    <p class="agenda-speakers">
                                        <i class="fa fa-fw fa-bullhorn"></i>
                                        @session.Speakers.Select(i =>
                                            i.FirstName + " " + i.LastName).Aggregate((i, j) => i + ", " + j)
                                    </p>
                                    <p class="agenda-location">
                                        <i class="fa fa-fw fa-map-marker"></i>
                                        @session.Rooms.Aggregate((i, j) => i + ", " + j)
                                    </p>
                                </div>
                            </td>
                        </tr>
                    }
                }
            </tbody>
        </table>
    </div>
</div>

I know, I know...tables. This is probably the best way to represent the data with a checkbox down the left side to make immediate changes to the user's agenda.

The JavaScript for our agenda screen includes clicking anywhere in the row and toggling the checkbox on and off as a way to quickly remove it from our agenda.

You will also notice that I do not remove the item from the list when a user unchecks the session.

My thoughts on this is that we all make mistakes and tap that wrong item every once in a while. I decided to let them toggle it on and off until they decide to leave the page to accept their changes.

We also need some CSS styles to make it look good.

Content\Site.css

.favorite { displaynone }
 
/* Agenda */
.agenda {  }
.agenda tbody tr[data-id] { displaynone }
 
/* Dates */
.agenda .agenda-date { width170px  }
.agenda .agenda-date .dayofmonth {
  width40px;
  font-size36px;
  line-height36px;
  floatleft;
  text-alignright;
  margin-right10px; 
}
.agenda .agenda-date .shortdate {
  font-size0.75em; 
}
 
 
/* Times */
.agenda .agenda-day-month { floatleft;}
.agenda .agenda-time { padding-left20pxfloatleftfont-size2em } 
 
 
/* Events */
.agenda .agenda-select {vertical-alignmiddle }
.agenda .agenda-event { white-spacenormalfont-size18pxfont-weightbold; }
.agenda .agenda-speakers,
.agenda .agenda-location {font-size14pxmargin0;font-weightnormal; }

And finally, we examine our JavaScript for the Agenda page.

// Agenda details.
if ($(".agenda").size() > 0) {
    $(".agenda-select, .agenda-events").on("click"function () {
        var row = $(this).closest("tr");
        var element = $("i", $(".agenda-select", row));
        var agendaId = $(row).attr("data-id");
        // not checked.
        if ($(element).hasClass("fa-square-o")) {
            db.get(agendaId, {}, function(error, doc) {
                if (error) {
                    if (error.status === 404) {
                        // Add a new doc.
                        element
                            .removeClass("fa-square-o")
                            .addClass("fa-check-square-o");
                        return db.put({
                            _id: agendaId
                        });
                    } else {
                        // some other error.
                        console.write(error);
                    }
                }
                return false;
            });
        } else {
            // Checked.
            db.get(agendaId, {}, function(error, doc) {
                return db.remove(doc, function() {
                    element
                        .removeClass("fa-check-square-o")
                        .addClass("fa-square-o");
                });
            });
        }
    });
 
    db.allDocs().then(function(result) {
        var rows = result.rows;
        $("tbody tr[data-id]").hide();
        $(rows).each(function (index, elem) {
            if (!elem._deleted) {
                $("tbody tr[data-id="+elem.id+"]").show();
            }
        });
    });
}

Most of this is standard "If this dom element has class, do something. If not, do something else" code.

But the real code is at the bottom. On initial load, we get all of the documents from pouchdb and display only the selected sessions based on id.

Our Agenda page now looks like this:

When you click on the row, it de-selects and removes the document from pouchdb, but not from the screen. Click it again and pouchdb saves the document to the database.

Conclusion

As we come to a close, this has been one eye-opening experience for me.

We took a full-fledged web application and optimized it by making it look like a native application in regards to speed and usability.

We've used a little of old and new tools to optimize our application.

I hope you've enjoyed this series and please let me know if you have any questions or concerns regarding the code.

Could you have done something different? What would you have changed? Post your comments below.

ASP.NET MVC Optimization Series: