Time App


This is an internal app we use in the company to track the time of the development and design team. We also create status reports and communicate the client all progress.



Design of UX and UI

The problem we were trying to solve was to make easier the process of logging time and reporting progress. We designed a simple to use UI for it.


Development

This app was created using Angular JS. The backend was done using Ruby on Rails.

Time App is deeply integrated with Trello which is our project management system. All time entries relate to a Trello card.

The development team was conformed by 1 engineer working full time.

require "rails_helper"

RSpec.describe TimeLogger, :type => :model do
  before(:each) do
    User.destroy_all
    Iteration.destroy_all
    Timelog.destroy_all
    stubbing_project_manager
  end
  let(:user) { create(:user) }
  subject{ TimeLogger.new(user)}

  describe "validate" do
    let!(:iteration){ create(:iteration, project_id:"is-an-id",start: DateTime.now - 1.day)}
    describe "presence" do
      it { is_expected.to validate_presence_of(:project_name) }
      it { is_expected.to validate_presence_of(:project_id) }
    end
  end

  describe "#register" do
    let(:timelog_1){ attributes_for(:timelog)}
    let(:timelog_2){ attributes_for(:timelog)}
    let(:attributes){ {project_id: "is-an-id", project_name: "this is my name", timelogs_attributes: [timelog_1,timelog_2]}}
    let(:time_logger){ TimeLogger.new(user)}

    describe "iteration" do
      describe "existing" do
        let!(:iteration){ create(:iteration, time: time, project_id:"is-an-id",start: DateTime.now - 1.day)}
        describe "iteration has more hours" do
          let(:time){ 30 }
          it{ expect{ time_logger.create(attributes) }.to change{Timelog.count}.by(2) }
        end
        describe "Record more hours than are available" do
          let(:time){ 1 }
          it{ expect{ time_logger.create(attributes) }.to change{Timelog.count}.by(2) }
        end

        describe "No record because no times available" do
          let(:time){ 1 }
          before {time_logger.create(attributes)}
          it{ expect{ time_logger.create(attributes) }.to change{Timelog.count}.by(0) }
        end
      end
      describe "not existing" do
        it{ expect{ time_logger.create(attributes) }.to change{Timelog.count}.by(0) }
      end
    end
  end
end
class Api::TimelogsController <  ApiAuthenticatedController

  def create
    @timelogs = TimeLogger.new(current_user)
    if @timelogs.create(timelogger_params)
      render json: @timelogs, status: 201, notice: "Successfully created Timelog"
    else
      render json: @timelogs.errors.messages, status: 422
    end
  end

  def update
    @timelog = Timelog.find(params[:id])
    if @timelog.update(timelog_params)
      render json: @timelog, status: 201, notice: "Successfully updated Timelog"
    else
      render json: @timelog.errors.messages, status: 422
    end
  end

  def index
    @timelogs =  Timelog.performance(params_search)
    render json: @timelogs
  end

  def get_by_card
    @timelogs =  Timelog.only(:task_id, :fecha, :time, :value_ajust, :comment).where(:task_id => params[:card_id], :fecha => params[:date_start]..params[:date_end])
    render json: @timelogs
  end

  def params_search
    params.permit(:date_1, :date_2, :project_id, :user_id)
  end

  def timelog_params
    params.require(:timelog).permit(:time,:comment)
  end

  def timelogger_params
    params.require(:timelogger).permit(:project_id, :project_name, timelogs_attributes: timelog_attributes)
  end

  def timelog_attributes
    [:task_id,:task_name, :comment, :time]
  end
end
class Iteration
  include Mongoid::Document

  field :project_id, type: String
  field :time, type: Float
  field :note, type: String
  field :start, type: Date, default: DateTime.now
  field :end_date, type: Date
  field :invoice, type: Integer

  validates_presence_of :start, :time
  validates_numericality_of :time

  scope :by_project, ->(project_id) { where(:project_id => project_id).order_by(:start.desc) }
  scope :closed, -> {where(end_date: nil)}

  before_create do |iteration|
    prev_iteration = Iteration.current_iteration(iteration.project_id)
    prev_iteration.close_iteration! if prev_iteration
  end

  def timelogs
    Timelog.where(iteration_id: self.id)
  end

  def self.current_iteration(project)
    where(:project_id => project, :start.lte => DateTime.now ).order_by(:start.desc).limit(1).first
  end

  def close_iteration!
    last_timelog = self.timelogs.last_registered(1).first
    self.end_date = last_timelog.fecha
    self.save
  end

  def update_dates
    self.start = first_log_date
    self.end_date = last_log_date
    self.save
  end

  def first_log_date
    self.timelogs.order_by(:fecha.asc).first.fecha
  end

  def update_end_date
    self.end_date = last_log_date
    self.save
  end

  def last_log_date
    last = self.timelogs.order_by(:fecha.asc).last
    last ? last.fecha : DateTime.now
  end

  def can_register_hours?
    time_for_iteration < time 
  end

  def remain_time
    time - time_for_iteration
  end

  def time_worked
    total_time = timelogs.inject(0.0){|total,timelog| total += timelog.time_worked }
    total_time.round(2)
  end

  def time_for_iteration
    total_time = timelogs.inject(0.0){|total,timelog| total += timelog.time_for_iteration }
    total_time.round(2)
  end

  def as_json(options = nil)
    super(options).merge({time_for_iteration: time_for_iteration})
  end
end
(function(){
  var TimeApp = window.TimeApp;
  var CacheStoreModule = angular.module('CacheStore', []);

  CacheStoreModule.config(['$provide', function($provide) {
    $provide.factory('ProjectCache',[function() {
      this.loadProjects = function(){
        return loadJsonCache('projects') || [];
      };

      this.saveProjects = function(new_projects){
        saveJsonCache('projects',new_projects);
      };

      this.findProject = function(project_id){
        this.projects = this.loadProjects();
        for (var i = this.projects.length - 1; i >= 0; i--) {
          project = this.projects[i];
          if(project.id == project_id)
            return project;
        };
      };

      this.projects = this.loadProjects(); 
      return this;
    }]);

    $provide.factory('IterationsCache',[function() {
      this.loadIterations = function(project_id){
        return loadJsonCache(project_id+'_iterarions') || [];
      };

      this.saveIterations = function(project_id, new_iterations){
        saveJsonCache(project_id+'_iterations',new_iterations);
      };

      return this;
    }]);

    $provide.factory('CardsCache',[function() {
      this.loadCards = function(project_id){
        return loadJsonCache(project_id+'_cards') || [];
      };

      this.saveCards = function(project_id, new_cards){ 
        saveJsonCache(project_id+'_cards',new_cards);
      };

      this.findCard = function(project_id,card_id){
        this.cards = this.loadCards(project_id);
        for (var i = this.cards.length - 1; i >= 0; i--) {
          card = this.cards[i];
          if(card.id == card_id)
            return card;
        };
      };

      this.getCardsByProjectId = function(project_id){
        this.cards = this.loadCards(project_id);
          return this.cards;
      };

      return this;
    }]);

    $provide.factory('ClientsCache',[function() {
      this.loadClients = function(){
        return loadJsonCache('clients') || [];
      };

      this.saveClients = function(new_clients){
        saveJsonCache('clients',new_clients);
      };
      this.findClient = function(client_id){
        this.clients = this.loadClients();
        for (var i = this.clients.length - 1; i >= 0; i--) {
          client = this.clients[i];
          if(client.id == client_id)
            return client;
        };
      }; 
      this.clients = this.loadClients();    
      return this;
    }]);

    $provide.factory('CurrentUser',["$location",function($location) {
      var currentUser;
      this.id= function(){
        currentUser.id;
      };

      this.isAuthenticated = function(){
        return currentUser.token_authentication != undefined;
      };

      this.changeUser = function(user){
        currentUser = user;
        $('meta[name="Token"]').attr('content',user.token_authentication);
      };

      this.token = function(){
       return currentUser.token_authentication;
      };

      this.saveCache = function(){
        saveJsonCache('currentUser',currentUser);
      };

      this.checkAuth = function(){      
        if(this.isAuthenticated()){
          $location.path('/');
        }
      };

      this.isPendingAuth = function(){      
        if( !this.isAuthenticated()){
          $location.path('/log-in');
        }
      };

      this.getUser = function(){
        return currentUser;
      };

      if( existCache("currentUser") )
        currentUser = new TimeApp.User( loadJsonCache("currentUser") );      
      else
        currentUser = new TimeApp.User();

      $('meta[name="Token"]').attr('content',currentUser.token_authentication);
      return this;
    }]);

  }]);

  var saveJsonCache = function(name,obj){
    localStorage[name] = JSON.stringify(obj);
  };

  var loadJsonCache = function(name){
    if(localStorage[name])
      return JSON.parse(localStorage[name]);
    else
      return undefined;
  };

  var existCache = function(name){
    return localStorage[name] != undefined;
  };
})();

Our Process

Meetings

We have weekly calls where we discuss progress. We use Skype, Google Hangouts or GotoMeeting.

We agreed on a day and time that worked for everyone.

Planning

In the beginning we send a timeline specifying the planning and tasks for that week.

These tasks are the same cards we have planned in Trello.

Trello

We translate all requirements into stories in Trello Cards.

Trello is the place to add any specification, ask questions or comments to the team.

Deliveries

Once the tasks are done they go through a testing process and we pass them for your approval.

Every week we will pass cards onto the client list for you to give us feedback.

Quality Assurance

Once the functionality is completed one of our functional testers will try to break the feature.

We either move it to Needs Improvement or pass it to you for feedback.

Feedback

We need the support from you to review the cards and Approve them or tell us how to improve it.

This is key to meet goals and being on time in the development of the application.