Barbarian Meets Coding Titlebarbarianmeetscoding

WebDev, UX & a Pinch of Fantasy

lean-ux

Lean UX, Atomic Design and Angular - A Flexible Front-end Architecture for Developing Web Applications: Part IV

Lean UX, Atomic Design and Angular Logos

Did you miss the other parts of this series? You may want to take a look at them first:

In the previous article of this series we introduced an imaginary Pomodoro web application. This app will let us become super productive in all our future endeavors and also serve as an example to illustrate the component-based (atomic design) front-end architecture this series advocates for.

In this article, we will start building the app following a traditional approach with Angular and relying in controllers bound to a view in a loose way (via ng-directive or a router config). We will describe the pros and cons of this solution along the way, and will encounter a couple of new requirements that will highlight the rigidness of this approach and its inability to handle changing requirements gracefully.

The Angular Way

When you start learning AngularJS and how to build web applications with it one of the first concepts you learn about is the controller and the ng-controller directive.

The controller acts as a glue between the domain model of your application and the view (your UI). It usually grabs a collection of domain objects, transforms them in some way and exposes them to the view. It also encapsulates the different interactions that a given user can perform on the domain objects within a specific view, like, let’s say remove or add tasks to a to-do list.

The framework itself nudges you gently to use controllers because it is the path of least resistance. Do you want some data displayed in the screen? Create a controller, augment the $scope (or this) with the data, bind the controller to a DOM element with ng-controller, the data using any of the built-in directives and you are done.

It is the path of least resistance and probably the quickest way to get something simple done but you’ll see how it has its inconveniences when new requirements arrive.

Creating a Pomodoro App: The “Monolithic” Approach

You can find the source code in GitHub and also test the actual app in this demo

We are going to start creating a pomodoro app using what I will call a “monolithic” approach, and I write it inside quotes because the only monolithic part will be the controller that encapsulates the pomodoro timer and the tasks list. The rest of the application will be properly factored in services and other controllers.

Limiting the monolith to the outermost part of the application, the one that directly communicates with the UI will serve to illustrate the rigidness of this approach while keeping the app easy to understand (since each service is devoted to a single responsibility).

We will create a Pomodoro app that will let us:

  • add/remove tasks that we want to complete during the day,
  • estimate how many pomodoros each task would take
  • select which tasks you work on
  • start/stop the timer (which would update the status of the task being executed)
  • archive tasks when they are complete
  • view archived tasks and key point indicators that summarize how productive I am
  • search/filter tasks
The Pomodoro app

Starting the App…

We start our app with a single view and a single controller, driving the development with tests:

describe('MainController', () => {
  let vm

  beforeEach(angular.mock.module('001Monolithic'))

  beforeEach(
    inject($controller => {
      vm = $controller('MainController', { tasksService })
    })
  )

  describe('Tasks', () => {
    it('should define 2 initial tasks', () => {
      // Arrange, Act, Assert
      expect(angular.isArray(vm.tasks)).toBeTruthy()
      expect(vm.tasks.length).toBe(2)
    })
  })
})

And making these tests pass little by little:

// I am using a class here to represent a controller
// it is equivalent to using a constructor function ^_^
export class MainController {
  constructor() {
    const vm = this

    Object.assign(vm, {
      // tasks
      tasks: getInitialTasks(), // gets two simple tasks
    })
  }
}

function getInitialTasks() {
  return [
    {
      title: 'Write dissertation on ewoks',
      pomodoros: 3,
      workedPomodoros: 0,
      isActive: true,
    },
    {
      title: 'Buy flowers for Malin',
      pomodoros: 1,
      workedPomodoros: 0,
      isActive: false,
    },
  ]
}
<div class="container" ng-controller="MainController as main">

  <!-- list tasks -->
  <section class="row">
    <div class="col-sm-6 col-sm-offset-3">

      <ul class="list-group">
          <li class="list-group-item" ng-repeat="task in main.tasks">
            {{ task.title }}

            <div class="pull-right">
                <span class="label label-default">{{ task.workedPomodoros }}</span>
                <span class="label label-success">{{ task.pomodoros }}</span>
            </div>
          </li>
      </ul>
    </div>
  </section>

</div>

I register the different parts of the application module within the same js file. Separation of concerns #ftw!

import { MainController } from './main/main.controller'

angular.module('001Monolithic', []).controller('MainController', MainController)

Fast forward some hours and I have a WALL-OF-CODE to show you. We have an app with the following components:

A sketch of the different components of the pomodoro app in its monolithic version
  • a router configuration that defines a couple of views in the app, one for daily tasks and another for stats and archived tasks
  • a MainController that contains the pomodoro timer and tasks logic (I know it’s not super descriptive but it’s a sympton that the controller is doing too much - I did also repurpose it from the generator-gulp-angular hehe, but we’ll fix it soon! Refactor! Refactor!). It uses a TasksService to get/save tasks and a SpeechService to sound the pomodoro timer alarm.
  • a StatsController that contains enough logic to display a series of archived tasks and compute some kpis associated to them
  • a TasksService that encapsulates operations on tasks such as retrieving them from localStorage, removing them, archiving a task
  • a LocalStorageService that is a wrapper over localStorage
  • a SpeechService that is a simple facade over the Web Speech API

And that’s pretty much it. You can see the full source code on GitHub but I’ll give you a brief walkthrough next.

The MainController with The Pomodoro Timer and Tasks List

The MainController manages both the pomodoro timer and the list of tasks we are going to work on daily.

export class MainController {
  constructor($interval, $log, tasksService, speechService) {
    'ngInject'
    const vm = this,
      currentTime = getPomodoroTime()

    Object.assign(vm, {
      // timer
      timeLeft: formatTime(currentTime),
      currentTime,
      // tasks
      tasks: tasksService.getTasks(),
      newTask: Task(),
      hasTasks() {
        return this.tasks.length > 0
      },
      filterTerm: '',

      // services
      $interval,
      $log,
      tasksService,
      speechService,
    })

    // angular and getters don't seem to work
    // very well together
    vm.activeTask = this.tasks.find(t => t.isActive)
  }

  // pomodoro timer stuff
  // task handling stuff
}

It contains both pomodoro specific logic:

export class MainController {
  // constructor
  // pomodoro timer stuff
  startPomodoro() {
    if (this.performingTask)
      throw new Error("Can't start a new pomodoro while one is already running")
    this.performingTask = true
    this.setTimerInterval(getPomodoroTime())
    this.speechService.say(
      'ring ring ring start pomodoro now! Time to kick some ass!'
    )
  }

  cancelPomodoro() {
    this.stopPomodoro()
    this.resetPomodoroTimer()
  }

  advanceTimer() {
    this.$log.debug('advancing timer 1 second')
    this.currentTime -= 1
    if (this.currentTime === 0) {
      if (this.performingTask) this.completePomodoro()
      else this.completeRest()
    } else this.timeLeft = formatTime(this.currentTime)
  }

  stopPomodoro() {
    this.performingTask = false
    this.cleanInterval()
    this.speechService.say('Stop! Time to rest and reflect!')
  }

  completePomodoro() {
    this.activeTask.workedPomodoros++
    this.stopPomodoro()
    this.startRest()
  }

  startRest() {
    this.resting = true
    this.setupRestTime()
    this.setTimerInterval(getRestTime(this.activeTask))
  }
  setTimerInterval(seconds) {
    //console.log('create interval');
    this.cleanInterval() // make sure we release all intervals
    this.timerInterval = this.$interval(
      this.advanceTimer.bind(this),
      1000,
      seconds
    )
  }

  completeRest() {
    this.cleanInterval()
    this.resting = false
    this.resetPomodoroTimer()
  }

  cleanInterval() {
    if (this.timerInterval) {
      //console.log('stopping interval');
      this.$interval.cancel(this.timerInterval)
      this.timerInterval = null
    }
  }

  resetPomodoroTimer() {
    this.setTime(getPomodoroTime())
  }

  setupRestTime() {
    this.setTime(getRestTime(this.activeTask))
  }

  setTime(time) {
    this.currentTime = time
    this.timeLeft = formatTime(this.currentTime)
  }

  // task handling stuff
}

And task handling logic:

export class MainController {
  // constructor
  // pomodoro timer stuff
  // task handling stuff

  addNewTask() {
    this.tasks.push(this.newTask)
    if (this.tasks.length === 1) this.setTaskAsActive(this.newTask)
    this.tasksService.saveTask(this.newTask)

    this.newTask = Task()
  }

  removeTask(task) {
    const index = this.tasks.indexOf(task)
    if (index !== -1) {
      if (task.isActive) this.setNextTaskAsActive(index)
      this.tasks.splice(index, 1)
      this.tasksService.removeTask(task)
    }
  }

  archiveTask(task) {
    const index = this.tasks.indexOf(task)
    if (index !== -1) {
      let [taskToArchive] = this.tasks.splice(index, 1)
      this.tasksService.archiveTask(taskToArchive)
    }
  }

  setNextTaskAsActive(index) {
    if (this.tasks.length > index + 1)
      this.setTaskAsActive(this.tasks[index + 1])
    else if (index > 0) this.setTaskAsActive(this.tasks[index - 1])
  }

  setTaskAsActive(task) {
    const currentActiveTask = this.tasks.find(t => t.isActive)
    if (currentActiveTask) currentActiveTask.isActive = false
    task.isActive = true
    this.activeTask = task
  }
}

The MainController is bound to the main.html view:

<div class="container">

  <!-- timer -->
  <section class="timer text-center">
    <h1>{{main.timeLeft}}</h1>
    <p class="animated infinite" ng-class="main.classAnimation">
      <button type="button" class="btn btn-lg btn-success" ng-click="main.startPomodoro()" ng-if="!main.performingTask" ng-disabled="!main.hasTasks()">Ready, Set, Go!</button>
      <button type="button" class="btn btn-lg btn-error" ng-click="main.cancelPomodoro()" ng-if="main.performingTask">Cancel Pomodoro!</button>
    </p>
  </section>

  <!-- tasks -->
  <!-- add new tasks form -->
  <section class="row row--with-margins">
    <div class="col-sm-6 col-sm-offset-3">
        <form class="row">
            <div class="col-xs-6">
                <input type="text" class="form-control" placeholder="title" ng-model="main.newTask.title">
            </div>
            <div class="col-xs-2">
                <input type="number" class="form-control" placeholder="pomodoros" ng-model="main.newTask.pomodoros">
            </div>
            <div class="col-xs-4">
                <button class="btn btn-default btn-block" ng-click="main.addNewTask()">Add new task</button>
            </div>
        </form>
    </div>
  </section>
  <!-- list tasks -->
  <section class="row">
    <div class="col-sm-6 col-sm-offset-3">

      <input type="text" class="form-control" placeholder="search..." ng-model="main.searchTerm">
      <ul class="list-group list-group-tasks list-group-tasks-current">
          <li class="list-group-item" ng-repeat="task in main.tasks | filter:main.searchTerm" ng-class="{active: task.isActive}">
              <span class="btn btn-default" ng-click="main.setTaskAsActive(task)" ng-class="{'btn-active': task.isActive}"><i class="glyphicon glyphicon-play-circle"></i></span>
            {{ task.title }}

            <div class="pull-right">
                <button class="btn btn-default pull-right" ng-click="main.archiveTask(task)"><i class="glyphicon glyphicon-ok"></i></button>
                <button class="btn btn-default pull-right" ng-click="main.removeTask(task)"><i class="glyphicon glyphicon-remove"></i></button>
            </div>

            <div class="pull-right task-pomodoros">
                <span class="label label-default">{{ task.workedPomodoros }}</span>
                <span class="label label-success">{{ task.pomodoros }}</span>
            </div>
          </li>
      </ul>
    </div>
  </section>

</div>

The Task, Local Storage and Speech Services

The TasksService encapsulates operations with tasks and uses the LocalStorageService to persist tasks in your browser local storage:

const TASKS_KEY = 'tasks',
  ARCHIVED_TASKS_KEY = 'archived tasks'

export class TasksService {
  constructor($log, localStorageService) {
    'ngInject'

    Object.assign(this, {
      tasks: new Map(),
      archivedTasks: new Map(),
      localStorageService,
      $log,
    })

    this.loadTasks()
  }

  loadTasks() {
    this.loadActiveTasks()
    this.loadArchivedTasks()
  }

  loadActiveTasks() {
    let tasks = this.localStorageService.get(TASKS_KEY)
    if (tasks === null) {
      this.initializeLocalStorage(TASKS_KEY, getInitialTasks())
      tasks = getInitialTasks()
    }
    this.$log.debug('restored tasks from local storage: ', tasks)
    tasks.forEach(t => this.tasks.set(t.id, t))
    fixDatesAfterDeserialization(tasks)
  }

  loadArchivedTasks() {
    let tasks = this.localStorageService.get(ARCHIVED_TASKS_KEY)
    if (tasks === null) {
      this.initializeLocalStorage(ARCHIVED_TASKS_KEY, [])
      tasks = []
    }
    this.$log.debug('restored archived tasks from local storage: ', tasks)
    tasks.forEach(t => this.archivedTasks.set(t.id, t))
    fixDatesAfterDeserialization(tasks)
  }

  getTasks() {
    return [...this.tasks.values()]
  }

  getArchivedTasks() {
    return [...this.archivedTasks.values()]
  }

  saveTask(task) {
    this.tasks.set(task.id, task)
    this.saveTasks()
  }

  archiveTask(task) {
    this.tasks.delete(task.id)
    this.archivedTasks.set(task.id, task)
    this.saveTasks()
    this.saveArchivedTasks()
  }

  removeTask(task) {
    this.tasks.delete(task.id)
    this.saveTasks()
  }

  removeArchivedTask(task) {
    this.archivedTasks.delete(task.id)
    this.saveArchivedTasks()
  }

  saveTasks() {
    this.localStorageService.set(TASKS_KEY, [...this.tasks.values()])
  }

  saveArchivedTasks() {
    this.localStorageService.set(ARCHIVED_TASKS_KEY, [
      ...this.archivedTasks.values(),
    ])
  }

  initializeLocalStorage(key, value) {
    this.localStorageService.set(key, value)
  }
}

export function Task({ title = '', pomodoros = 1, isActive = false } = {}) {
  return {
    title,
    pomodoros,
    workedPomodoros: 0,
    isActive: isActive,
    id: guid(),
    timestamp: new Date(),
  }
}

function getInitialTasks() {
  return [
    Task({
      title: 'Write dissertation on ewoks',
      pomodoros: 3,
      isActive: true,
    }),
    Task({ title: 'Buy flowers for Malin', pomodoros: 1, isActive: false }),
  ]
}

function fixDatesAfterDeserialization(tasks) {
  tasks.forEach(t => (t.timestamp = new Date(t.timestamp)))
}

The LocalStorageService is a wrapper of the browser’s local storage API:

export class LocalStorageService {
  constructor() {
    'ngInject'
  }

  get(key) {
    return angular.fromJson(localStorage.getItem(key))
  }

  set(key, value) {
    localStorage.setItem(key, angular.toJson(value))
  }
}

And the SpeechService is a facade over the browser’s Web Speech API:

export class SpeechService {
  constructor() {
    'ngInject'
  }

  say(sentence) {
    if (!window.SpeechSynthesisUtterance || !window.speechSynthesis) return

    var s = new SpeechSynthesisUtterance(sentence)
    s.lang = 'en-US'
    speechSynthesis.speak(s)
  }
}

The Stats

The StatsController encapsulates some task handling logic and kpi computation:

export class StatsController {
  constructor(tasksService, $scope) {
    'ngInject'
    const archivedTasks = tasksService.getArchivedTasks()

    Object.assign(
      this,
      {
        archivedTasks,
        searchTerm: '',
        // services
        tasksService,
      },
      getKPIs(archivedTasks)
    )

    $scope.$watch('stats.searchTerm', searchTerm => {
      const filteredTasks = this.archivedTasks.filter(t =>
        t.title.toLowerCase().includes(searchTerm.toLowerCase())
      )
      Object.assign(this, getKPIs(filteredTasks))
    })
  }

  removeTask(task) {
    const index = this.archivedTasks.indexOf(task)
    if (index !== -1) {
      this.archivedTasks.splice(index, 1)
      this.tasksService.removeArchivedTask(task)
    }
  }
}

// this would've been awesome for testing haha
// extract to service
function getKPIs(tasks) {
  if (tasks.length === 0) return NO_TASKS_KPIS

  const allEstimatedPomodoros = getAllEstimatedPomodoros(tasks)
  const allWorkedPomodoros = getAllWorkedPomodoros(tasks)

  const pomodorosPerDay = getPomodorosPerDay(tasks)
  const totalsPerDay = getTotalsPerDay(pomodorosPerDay)
  const maxWorkedPomodorosPerDay = getMaxWorkedPomodorosPerDay(pomodorosPerDay)

  return {
    averageEstimatedPomodoros: allEstimatedPomodoros / tasks.length,
    averageWorkedPomodoros: allWorkedPomodoros / tasks.length,
    averageEstimatedPomodorosPerDay:
      totalsPerDay.pomodoros / pomodorosPerDay.size,
    averageWorkedPomodorosPerDay:
      totalsPerDay.workedPomodoros / pomodorosPerDay.size,
    maxWorkedPomodorosPerDay,
  }
}

const NO_TASKS_KPIS = {
  averageEstimatedPomodoros: 0,
  averageWorkedPomodoros: 0,
  averageEstimatedPomodorosPerDay: 0,
  averageWorkedPomodorosPerDay: 0,
  maxWorkedPomodorosPerDay: 0,
}

function getAllEstimatedPomodoros(tasks) {
  return tasks.map(t => t.pomodoros).reduce((a, t) => a + t, 0)
}

function getAllWorkedPomodoros(tasks) {
  return tasks.map(t => t.workedPomodoros).reduce((a, t) => a + t, 0)
}

function getPomodorosPerDay(tasks) {
  return tasks.reduce((d, t) => {
    if (!d.has(t.timestamp.toDateString())) {
      d.set(t.timestamp.toDateString(), {
        pomodoros: t.pomodoros,
        workedPomodoros: t.workedPomodoros,
      })
    } else {
      const pomodorosThisDay = d.get(t.timestamp.toDateString())
      pomodorosThisDay.pomodoros += t.pomodoros
      pomodorosThisDay.workedPomodoros += t.workedPomodoros
    }
    return d
  }, new Map())
}

function getTotalsPerDay(pomodorosPerDay) {
  return Array.from(pomodorosPerDay.values()).reduce(
    (a, t) => {
      return {
        pomodoros: a.pomodoros + t.pomodoros,
        workedPomodoros: a.workedPomodoros + t.workedPomodoros,
      }
    },
    { pomodoros: 0, workedPomodoros: 0 }
  )
}

function getMaxWorkedPomodorosPerDay(pomodorosPerDay) {
  return Array.from(pomodorosPerDay.values()).reduce((a, t) => {
    return a > t.workedPomodoros ? a : t.workedPomodoros
  }, 0)
}

And it’s bound to the stats.html view:

<section class="container">

  <!-- KPI -->
  <section class="row">
      <div class="col-sm-6 col-sm-offset-3 kpis">
        <div class="panel panel-default panel-kpi">
          <div class="panel-heading">Average Estimated Pomo's</div>
          <div class="panel-body">
              {{stats.averageEstimatedPomodoros | number}}
          </div>
        </div>
        <div class="panel panel-default panel-kpi">
          <div class="panel-heading">Average Worked Pomo's</div>
          <div class="panel-body">
              {{stats.averageWorkedPomodoros | number}}
          </div>
        </div>
        <div class="panel panel-default panel-kpi">
          <div class="panel-heading">Average Estimated Pomo's/Day</div>
          <div class="panel-body">
              {{stats.averageEstimatedPomodorosPerDay | number}}
          </div>
        </div>
        <div class="panel panel-default panel-kpi">
          <div class="panel-heading">Average Worked Pomo's/Day</div>
          <div class="panel-body">
              {{stats.averageWorkedPomodorosPerDay | number}}
          </div>
        </div>
        <div class="panel panel-default panel-kpi">
          <div class="panel-heading">Max Worked Pomo's/Day</div>
          <div class="panel-body">
              {{stats.maxWorkedPomodorosPerDay | number}}
          </div>
        </div>
     </div>
 </section>

  <!-- tasks -->
  <!-- list tasks -->
  <section class="row">
    <div class="col-sm-6 col-sm-offset-3">

      <input type="text" class="form-control" placeholder="search..." ng-model="stats.searchTerm">
      <ul class="list-group list-group-tasks">
          <li class="list-group-item" ng-repeat="task in stats.archivedTasks | filter:stats.searchTerm">
            <span class="list-group-item-text">{{ task.title }}</span>

            <div class="pull-right">
                <button class="btn btn-default pull-right" ng-click="stats.removeTask(task)"><i class="glyphicon glyphicon-remove"></i></button>
            </div>

            <div class="pull-right task-pomodoros">
                <span class="label label-default">{{ task.workedPomodoros }}</span>
                <span class="label label-success">{{ task.pomodoros }}</span>
            </div>
          </li>
      </ul>
    </div>
  </section>

</section>

The result is this pomodoro app that you can start using right now! Hell yeah!

The Pomodoro app

The Pros and The Cons

Ok, now let’s examine some of the pros of cons of this approach thus far…

In this particular design of the app the MainController has many responsibilities, it handles the pomodoro timer and adding, removing, archiving and filtering tasks. Even though it delegates part of the task handling to the TasksService, the amount of logic has grown pretty much and following what the MainController is doing starts becoming hard. Testing also becomes more difficult which is a great hint for refactoring:

describe('MainController', () => {
  let vm, interval, tasksService

  beforeEach(angular.mock.module('001Monolithic'))

  // we have $interval and tasksService as a dependency for each test
  // but in reality only the timer uses $interval
  // and only task handling uses the tasksService
  beforeEach(inject(($controller, $interval, _tasksService_) => {}))

  // tests...
})

Factoring the app into two separate controllers (and later into components) would simplify our app and tests greatly because the pomodoro timer wouldn’t need to know about tasks, and vice versa, the tasks wouldn’t need to know about the pomodoro timer. One responsibility, less code, easier to understand and easier to test.

A pro of the monolith however is that orchestrating the pomodoro timer with the active task is very straightforward, since both timer and active task reside within the same controller.

In summary:

  • Pros of the Monolith

    • Orchestrating timer and active task is easy
  • Cons of the Monolith

    • Too many responsibilities within a single controller.
    • Harder to understand
    • Harder to test
    • Harder to reuse

Up Next. Adapting to Changing Requirements!

Ok, so now that we have a baseline, let’s try to get some more requirements, see how this app reacts to these new requirements, and then make a case for a component-based architecture.

See you soon! Have a great week ahead!


Jaime González García

Written by Jaime González García , Dad, Husband, Front-end software engineer, UX designer, amateur pixel artist, tinkerer and master of the arcane arts. You should follow him on Twitter where he shares useful stuff! (and is funny too).Jaime González García