Skip to Content
Course content

247: Building a Time-Tracking Application API

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You might be tempted to think that the most efficient way to build a time-tracking API is to simply store a duration column (perhaps as an integer of seconds) in your database. It seems intuitive: when the user hits "Stop," you calculate the difference between the start and end times and save that single number. It feels clean, and it makes your SUM() queries in SQL incredibly fast.

The Trap of the Duration Column

Here is why that approach will bite you. Imagine your user starts a timer at 9:00 AM and stops it at 10:30 AM. You store 5400 seconds. Everything looks great until the client asks for a "Daily Timeline" view. Suddenly, you need to show that the user was active between 9:00 and 10:30. If you only stored the duration, that temporal data is gone forever. You can't reconstruct the timeline; you've effectively deleted the "when" and only kept the "how long."

Tracking Intervals, Not Sums

The correct way to handle this is to treat time as an interval. You need two timestamps: started_at and stopped_at. This preserves the integrity of the data. If you need the duration, you calculate it on the fly or use a virtual attribute. I've found that relying on the database to hold the "truth" of the timestamps and letting Ruby handle the duration logic keeps the API flexible.

class TimeEntry < ApplicationRecord
  # We assume started_at and stopped_at are datetime columns
  
  def duration
    return 0 unless started_at
    
    # If the timer is still running, we calculate duration up to now
    end_time = stopped_at || Time.current
    (end_time - started_at).to_i # Returns duration in seconds
  end

  def active?
    stopped_at.nil?
  end
end

Handling the 'Still Running' Timer

In a real-world API, you'll run into the "zombie timer" problem—where a user starts a timer and then closes their browser or their laptop dies. If your API endpoint for GET /entries simply subtracts stopped_at from started_at, any active timer will return a nil or an error because stopped_at is empty.

I usually handle this by implementing a helper method that treats a nil end time as "right now." This ensures that your API always returns a current duration for active tasks, which makes the frontend feel responsive and live.

# In your controller or a serializer
def render_time_entry(entry)
  {
    id: entry.id,
    project_id: entry.project_id,
    started_at: entry.started_at,
    stopped_at: entry.stopped_at,
    duration_seconds: entry.duration, # Uses our model method from above
    status: entry.active? ? 'running' : 'completed'
  }
end

Structuring the API Endpoints for State Changes

Avoid creating generic "update" endpoints for starting and stopping timers. While PATCH /time_entries/1 works, it's imprecise. I prefer explicit action endpoints. It makes your logs easier to read and your business logic easier to isolate. Instead of sending a payload with a timestamp, just hit a toggle.

  • POST /time_entries: Creates a new entry and sets started_at to the current time.
  • POST /time_entries/:id/stop: Sets stopped_at to the current time.

This approach removes the risk of the client sending a skewed timestamp from their local machine, which is a nightmare to debug when users are in different time zones. Always trust the server's clock for audit-trail data.




📋 Practical Task

Implement a Project Totalizer Endpoint

You have a Project model that has_many :time_entries. Your goal is to add a custom action to your API that calculates the total time spent on a project, including the time elapsed on any entries that are currently still running.

Requirements:

  • Create a route for GET /projects/:id/total_time.
  • In the controller, sum the durations of all associated TimeEntry records.
  • Ensure that entries where stopped_at is nil are calculated up to the current moment (Time.current).
  • The response should be a JSON object containing the project_id and the total_seconds.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.