[TUT] Creating comments with timestamps like YouTube

Creating comments with timestamps like YouTube (java.util.Date or JodaTime)

This tutorial shows how you can create timestamped messages like the YouTube comments section. What this means is we will convert the timestamp (date) that each message has been saved at into a human readable and rounded format. For instance if today is 2016/02/14 17:00 and someone had commented at 2016/02/14:12:55 the message would be timestamped with “2 hours ago”.

What we are going to do:

– Create an example app showing a list of items
– Understand the different time possibilities
– Test Drive the creation of our formatted timestamp
– Show an alternative in JodaTime

Ok here .. we .. go

First up is just the setup of the example application. It has a ListView (sure use RecyclerView I don’t care) that will hold the comments and timestamps. Each of our comments in this example has a message and a timestamp.

Comment.java

import java.util.Date;

class Comment {

    private final Date timestamp;
    private final String message;

    public Comment(Date timestamp, String message) {
        this.timestamp = timestamp;
        this.message = message;
    }

    public Date getTimestamp() {
        return timestamp;
    }

    public String getMessage() {
        return message;
    }
}
  

I think you all know the ViewHolder pattern and how ListView’s work, so here’s the code with not much explanation:

list_item_comment.xml

  <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="vertical"
  android:padding="@dimen/activity_vertical_margin">

  <TextView
    android:id="@+id/comment_timestamp"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:gravity="end"
    android:textAppearance="@android:style/TextAppearance.Small"
    tools:text="3 days ago" />

  <TextView
    android:id="@+id/comment_message"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:textAppearance="@android:style/TextAppearance.Medium"
    tools:text="this is so cool!" />

</LinearLayout>
  

CommentAdapter.java

class CommentAdapter extends BaseAdapter {

    private final List<Comment> comments = new ArrayList<>();

    private final LayoutInflater layoutInflater;

    public CommentAdapter(LayoutInflater layoutInflater) {
        this.layoutInflater = layoutInflater;
    }

    public void updateWith(List<Comment> comments) {
        this.comments.clear();
        this.comments.addAll(comments);
        notifyDataSetChanged();
    }

    @Override
    public int getCount() {
        return comments.size();
    }

    @Override
    public Comment getItem(int position) {
        return comments.get(position);
    }

    @Override
    public long getItemId(int position) {
        return position;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        View view;
        if (convertView == null) {
            view = layoutInflater.inflate(R.layout.list_item_comment, parent, false);
            TextView messageWidget = (TextView) view.findViewById(R.id.comment_message);
            TextView timestampWidget = (TextView) view.findViewById(R.id.comment_timestamp);
            ViewHolder viewHolder = new ViewHolder();
            viewHolder.messageWidget = messageWidget;
            viewHolder.timestampWidget = timestampWidget;
            view.setTag(viewHolder);
        } else {
            view = convertView;
        }
        ViewHolder viewHolder = (ViewHolder) view.getTag();
        Comment comment = getItem(position);
        viewHolder.messageWidget.setText(comment.getMessage());
        viewHolder.timestampWidget.setText(comment.getTimestamp().toString());
        return view;
    }

    public static class ViewHolder {
        TextView messageWidget;
        TextView timestampWidget;
    }
}
  

Tieing it all together is our MainActivity and the data. The data is just a static list of 10 items with lorem ipsum as text. So we create our activity, find our listview, add our adapter and load it with our data. Simple!

MainActivity.java

  public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ListView listWidget = (ListView) findViewById(R.id.main_comments);
        CommentAdapter adapter = new CommentAdapter(getLayoutInflater());
        List<Comment> comments = MockBackend.loadComments();
        adapter.updateWith(comments);
        listWidget.setAdapter(adapter);
    }

}

When this is all coded and running you should get a demo application that looks like this:
timestamps-before

Now lets get down to business, YouTube shows all it’s comments with a timestamp, however this is not the actual specific time that the comment was sent. For example

Screen Shot 2016-02-19 at 3.03.59 pm

It appears as though YouTube will stamp the comments with “just now”, “X minutes ago”, “X hours ago”, “X days ago”, “X months ago” and “X years ago”. This means we need to work out the difference between the current time and the commented time to calculate this string.
Making this calculation sounds straightforward but actually quite tricky to get right. It sounds like a perfect place to write some tests first and make sure what we are doing is right as we are doing it.
If you’ve never done TDD before there is no need to worry, you won’t even realise you are doing it and it’ll make so much sense afterwards!

The first scenario for our YouTube comments is “just now” this is when someone has commented under a minute ago.
A test for this could look like:

    @Test
    public void givenCommentedUnder60SecondsAgo_thenFormatSaysJustNow() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        Date commentedAt = minusSeconds(59, now());

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("just now", commentedAtFormatted);
    }

The only complicated thing here is the creation of the commentedAt variable. It helps if you think of it backwards – we are creating a java.util.Date in the now() method and then we are taking away 59 seconds.
Check it out

    private Date minusSeconds(int seconds, Date date) {
        date.setTime(date.getTime() - TimeUnit.SECONDS.toMillis(seconds));
        return date;
    }

To get this test passing we need to get the current time and get the commented time, if we then take them away from each other and convert this to minutes, if the minutes is under 1 then we return “just now”!

start of TimeStampFormatter.java

public String format(Date timestamp) {
        long commentedAtMillis = timestamp.getTime();
        long nowMillis = System.currentTimeMillis();
        long millisFromNow = nowMillis - commentedAtMillis;

        long minutesFromNow = TimeUnit.MILLISECONDS.toMinutes(millisFromNow);
        if (minutesFromNow < 1) {
            return "just now";
        }
        return "TODO";
}

Great our test passes! This was finding the difference in minutes and checking if it was under one, if we go on to find the difference in hours, days, weeks, years then we can complete our task. Don’t worry I’ve done it for you.

Test it one step of a time of course:

JavaUtilDate_TimeStampFormatter.java

import java.util.Date;
import java.util.concurrent.TimeUnit;

import org.junit.Test;

import static org.junit.Assert.assertEquals;

public class JavaUtilDate_TimeStampFormatterTest {

    private static final int DAYS_IN_A_WEEK = 7;
    private static final int AVERAGE_DAYS_IN_A_MONTH = 30;
    private static final int DAYS_IN_YEAR = 365; // We don't need the level of accuracy of leap years

    @Test
    public void givenCommentedUnder60SecondsAgo_thenFormatSaysJustNow() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        Date commentedAt = minusSeconds(59, now());

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("just now", commentedAtFormatted);
    }

    @Test
    public void givenCommented1MinuteAgo_thenFormatSays1MinuteAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        Date commentedAt = minusMinutes(1, now());

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("1 minute ago", commentedAtFormatted);
    }

    @Test
    public void givenCommentedUnder60MinuteAgo_thenFormatSaysXMinutesAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        Date commentedAt = minusMinutes(59, minusSeconds(59, now()));

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("59 minutes ago", commentedAtFormatted);
    }

    @Test
    public void givenCommented1HourAgo_thenFormatSays1HourAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        Date commentedAt = minusHours(1, now());

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("1 hour ago", commentedAtFormatted);
    }

    @Test
    public void givenCommentedUnder24HoursAgo_thenFormatSaysXHoursAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        Date commentedAt = minusHours(23, minusMinutes(59, now()));

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("23 hours ago", commentedAtFormatted);
    }

    @Test
    public void givenCommented1DayAgo_thenFormatSays1DayAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        Date commentedAt = minusDays(1, now());

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("1 day ago", commentedAtFormatted);
    }

    @Test
    public void givenCommentedLessThan7DayAgo_thenFormatSaysXDaysAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        Date commentedAt = minusDays(6, minusHours(23, minusMinutes(59, now())));

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("6 days ago", commentedAtFormatted);
    }

    @Test
    public void givenCommented1WeekAgo_thenFormatSays1WeekAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        Date commentedAt = minusWeeks(1, now());

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("1 week ago", commentedAtFormatted);
    }

    @Test
    public void givenCommentedLessThan4WeeksAgo_thenFormatSaysXWeeksAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        Date commentedAt = minusWeeks(3, minusDays(6, minusHours(23, minusMinutes(59, now()))));

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("3 weeks ago", commentedAtFormatted);
    }

    @Test
    public void givenCommented1MonthAgo_thenFormatSays1MonthAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        Date commentedAt = minusMonths(1, now());

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("1 month ago", commentedAtFormatted);
    }

    @Test
    public void givenCommentedLessThan1YearAgo_thenFormatSaysXMonthsAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        Date commentedAt = minusMonths(11, minusWeeks(3, minusDays(6, minusHours(23, minusMinutes(59, now())))));

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("11 months ago", commentedAtFormatted);
    }

    @Test
    public void givenCommented1YearAgo_thenFormatSays1YearAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        Date commentedAt = minusYears(1, now());

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("1 year ago", commentedAtFormatted);
    }

    @Test
    public void givenCommentedOver1YearAgo_thenFormatSaysXYearsAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        Date commentedAt = minusYears(2, minusMonths(11, minusWeeks(3, minusDays(6, minusHours(23, minusMinutes(59, now()))))));

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("2 years ago", commentedAtFormatted);
    }

    private Date minusSeconds(int seconds, Date date) {
        return minus(date, TimeUnit.SECONDS.toMillis(seconds));
    }

    private Date minusMinutes(int minutes, Date date) {
        return minus(date, TimeUnit.MINUTES.toMillis(minutes));
    }

    private Date minusHours(int hours, Date date) {
        return minus(date, TimeUnit.HOURS.toMillis(hours));
    }

    private Date minusDays(int days, Date date) {
        return minus(date, TimeUnit.DAYS.toMillis(days));
    }

    private Date minusWeeks(int weeks, Date date) {
        return minus(date, TimeUnit.DAYS.toMillis(weeks * DAYS_IN_A_WEEK));
    }

    private Date minusMonths(int months, Date date) {
        return minus(date, TimeUnit.DAYS.toMillis(months * AVERAGE_DAYS_IN_A_MONTH));
    }

    private Date minusYears(int years, Date date) {
        return minus(date, TimeUnit.DAYS.toMillis(years * DAYS_IN_YEAR));
    }

    private Date minus(Date date, long minutesInMillis) {
        date.setTime(date.getTime() - minutesInMillis);
        return date;
    }

    private Date now() {
        return new Date();
    }
}

Then the implementation that makes the tests pass. (I wrote this one section at a time after I wrote each test, but I am showing you the whole thing).

TimeStampFormatter.java

import java.util.Date;
import java.util.concurrent.TimeUnit;

class TimeStampFormatter {

    /**
     * For use with java.util.Date
     */
    public String format(Date timestamp) {
        long millisFromNow = getMillisFromNow(timestamp);

        long minutesFromNow = TimeUnit.MILLISECONDS.toMinutes(millisFromNow);
        if (minutesFromNow < 1) {
            return "just now";
        }
        long hoursFromNow = TimeUnit.MILLISECONDS.toHours(millisFromNow);
        if (hoursFromNow < 1) {
            return formatMinutes(minutesFromNow);
        }
        long daysFromNow = TimeUnit.MILLISECONDS.toDays(millisFromNow);
        if (daysFromNow < 1) {
            return formatHours(hoursFromNow);
        }
        long weeksFromNow = TimeUnit.MILLISECONDS.toDays(millisFromNow) / 7;
        if (weeksFromNow < 1) {
            return formatDays(daysFromNow);
        }
        long monthsFromNow = TimeUnit.MILLISECONDS.toDays(millisFromNow) / 30;
        if (monthsFromNow < 1) {
            return formatWeeks(weeksFromNow);
        }
        long yearsFromNow = TimeUnit.MILLISECONDS.toDays(millisFromNow) / 365;
        if (yearsFromNow < 1) {
            return formatMonths(monthsFromNow);
        }
        return formatYears(yearsFromNow);
    }

    private long getMillisFromNow(Date commentedAt) {
        long commentedAtMillis = commentedAt.getTime();
        long nowMillis = System.currentTimeMillis();
        return nowMillis - commentedAtMillis;
    }

    private String formatMinutes(long minutes) {
        return format(minutes, " minute ago", " minutes ago");
    }

    private String formatHours(long hours) {
        return format(hours, " hour ago", " hours ago");
    }

    private String formatDays(long days) {
        return format(days, " day ago", " days ago");
    }

    private String formatWeeks(long weeks) {
        return format(weeks, " week ago", " weeks ago");
    }

    private String formatMonths(long months) {
        return format(months, " month ago", " months ago");
    }

    private String formatYears(long years) {
        return format(years, " year ago", " years ago");
    }

    private String format(long hand, String singular, String plural) {
        if (hand == 1) {
            return hand + singular;
        } else {
            return hand + plural;
        }
    }
}

This gives us our formatting just like YouTube!

timestamps

There is a little thing to talk about here. We have been making use of TimeUnit to do our conversion from minutes, hours etc to milliseconds. However because Weeks, Months & Years are not units of time, we had to convert them to Days and then multiply this by how many days we think are in these calendar units. i.e. If we want to find the number of Months, we convert our difference in milliseconds to Days and then divide that by what we think is the average days in the month (30). Therefore this is not 100% accurate but accurate enough for our purposes.

When you start getting serious with dates you realise java.util.Date is not fit for purpose and you may move to JodaTime. Wise move! JodaTime has inherent handling of weeks, months years so it is more accurate than our above method, but if you take a close look at the code repository you can see there isn’t much difference to the rest of our code. (But our tests are much simpler!)

Just because you are using JodaTime doesn’t mean you can skip testing that your code works. Lets do the same again with JodaTime.

JodaTime_TimeStampFormatterTest.java

 
import org.joda.time.DateTime;
import org.junit.Test;

import static org.junit.Assert.assertEquals;

public class JodaTime_TimeStampFormatterTest {

    @Test
    public void givenCommentedUnder60SecondsAgo_thenFormatSaysJustNow() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        DateTime commentedAt = DateTime.now().minusSeconds(59);

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("just now", commentedAtFormatted);
    }

    @Test
    public void givenCommented1MinuteAgo_thenFormatSays1MinuteAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        DateTime commentedAt = DateTime.now().minusMinutes(1);

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("1 minute ago", commentedAtFormatted);
    }

    @Test
    public void givenCommentedUnder60MinuteAgo_thenFormatSaysXMinutesAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        DateTime commentedAt = DateTime.now().minusMinutes(59).minusSeconds(59);

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("59 minutes ago", commentedAtFormatted);
    }

    @Test
    public void givenCommented1HourAgo_thenFormatSays1HourAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        DateTime commentedAt = DateTime.now().minusHours(1);

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("1 hour ago", commentedAtFormatted);
    }

    @Test
    public void givenCommentedUnder24HoursAgo_thenFormatSaysXHoursAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        DateTime commentedAt = DateTime.now().minusHours(23).minusMinutes(59);

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("23 hours ago", commentedAtFormatted);
    }

    @Test
    public void givenCommented1DayAgo_thenFormatSays1DayAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        DateTime commentedAt = DateTime.now().minusDays(1);

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("1 day ago", commentedAtFormatted);
    }

    @Test
    public void givenCommentedLessThan7DayAgo_thenFormatSaysXDaysAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        DateTime commentedAt = DateTime.now().minusDays(6).minusHours(23).minusMinutes(59);

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("6 days ago", commentedAtFormatted);
    }

    @Test
    public void givenCommented1WeekAgo_thenFormatSays1WeekAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        DateTime commentedAt = DateTime.now().minusWeeks(1);

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("1 week ago", commentedAtFormatted);
    }

    @Test
    public void givenCommentedLessThan4WeeksAgo_thenFormatSaysXWeeksAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        DateTime commentedAt = DateTime.now().minusWeeks(3).minusDays(6).minusHours(23).minusMinutes(59);

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("3 weeks ago", commentedAtFormatted);
    }

    @Test
    public void givenCommented1MonthAgo_thenFormatSays1MonthAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        DateTime commentedAt = DateTime.now().minusMonths(1);

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("1 month ago", commentedAtFormatted);
    }

    @Test
    public void givenCommentedLessThan1YearAgo_thenFormatSaysXMonthsAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        DateTime commentedAt = DateTime.now().minusMonths(11).minusWeeks(3).minusDays(6).minusHours(23).minusMinutes(59);

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("11 months ago", commentedAtFormatted);
    }

    @Test
    public void givenCommented1YearAgo_thenFormatSays1YearAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        DateTime commentedAt = DateTime.now().minusYears(1);

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("1 year ago", commentedAtFormatted);
    }

    @Test
    public void givenCommentedOver1YearAgo_thenFormatSaysXYearsAgo() throws Exception {
        TimeStampFormatter formatter = new TimeStampFormatter();
        DateTime commentedAt = DateTime.now().minusYears(2).minusMonths(11).minusWeeks(3).minusDays(6).minusHours(23).minusMinutes(59);

        String commentedAtFormatted = formatter.format(commentedAt);

        assertEquals("2 years ago", commentedAtFormatted);
    }
}

TimeStampFormatter.java

    public String format(DateTime commentedAt) {
        DateTime now = DateTime.now();
        Minutes minutesBetween = Minutes.minutesBetween(commentedAt, now);
        if (minutesBetween.isLessThan(Minutes.ONE)) {
            return "just now";
        }
        Hours hoursBetween = Hours.hoursBetween(commentedAt, now);
        if (hoursBetween.isLessThan(Hours.ONE)) {
            return formatMinutes(minutesBetween.getMinutes());
        }
        Days daysBetween = Days.daysBetween(commentedAt, now);
        if (daysBetween.isLessThan(Days.ONE)) {
            return formatHours(hoursBetween.getHours());
        }
        Weeks weeksBetween = Weeks.weeksBetween(commentedAt, now);
        if (weeksBetween.isLessThan(Weeks.ONE)) {
            return formatDays(daysBetween.getDays());
        }
        Months monthsBetween = Months.monthsBetween(commentedAt, now);
        if (monthsBetween.isLessThan(Months.ONE)) {
            return formatWeeks(weeksBetween.getWeeks());
        }
        Years yearsBetween = Years.yearsBetween(commentedAt, now);
        if (yearsBetween.isLessThan(Years.ONE)) {
            return formatMonths(monthsBetween.getMonths());
        }
        return formatYears(yearsBetween.getYears());
    }

    private String formatMinutes(long minutes) {
        return format(minutes, " minute ago", " minutes ago");
    }

    private String formatHours(long hours) {
        return format(hours, " hour ago", " hours ago");
    }

    private String formatDays(long days) {
        return format(days, " day ago", " days ago");
    }

    private String formatWeeks(long weeks) {
        return format(weeks, " week ago", " weeks ago");
    }

    private String formatMonths(long months) {
        return format(months, " month ago", " months ago");
    }

    private String formatYears(long years) {
        return format(years, " year ago", " years ago");
    }

    private String format(long hand, String singular, String plural) {
        if (hand == 1) {
            return hand + singular;
        } else {
            return hand + plural;
        }
    }

I haven’t done anything to do with localisation here, as this is not the example, but if you would like to see how this would be converted just leave a comment.

That’s it, two alternatives using java.util.Date and JodaTime to be able to format a timestamp like the YouTube comments stream.

Code is available on GitHub here.

Any questions just ask!

Leave a Reply

Your email address will not be published. Required fields are marked *