Option to show relative times in changes table

Derived from the original implementation in JGit's RelativeDateFormatter
and adapted to the GWT environment. A new preference setting allows the
user to decide if he wants absolute or relative dates.

Myself as the original author and SAP agree to relicense the code
borrowed from JGit under Apache 2 license for use in Gerrit Code Review.

Change-Id: Id130f76a5937ad0d4f2e9a1b5f9f805f301b782c
Signed-off-by: Matthias Sohn <matthias.sohn@sap.com>
diff --git a/gerrit-gwtui/.settings/org.eclipse.core.resources.prefs b/gerrit-gwtui/.settings/org.eclipse.core.resources.prefs
index e9441bb..f9fe345 100644
--- a/gerrit-gwtui/.settings/org.eclipse.core.resources.prefs
+++ b/gerrit-gwtui/.settings/org.eclipse.core.resources.prefs
@@ -1,3 +1,4 @@
 eclipse.preferences.version=1
 encoding//src/main/java=UTF-8
+encoding//src/test/java=UTF-8
 encoding/<project>=UTF-8
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
index bc40097..ee9fa4f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/FormatUtil.java
@@ -119,6 +119,11 @@
     }
   }
 
+  /** Format a date using git log's relative date format. */
+  public static String relativeFormat(Date dt) {
+    return RelativeDateFormatter.format(dt);
+  }
+
   @Deprecated
   public static String nameEmail(com.google.gerrit.common.data.AccountInfo acct) {
     return nameEmail(asInfo(acct));
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java
new file mode 100644
index 0000000..3298a06
--- /dev/null
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client;
+
+import com.google.gerrit.client.changes.Util;
+
+import java.util.Date;
+
+/**
+ * Formatter to format timestamps relative to the current time using time units
+ * in the format defined by {@code git log --relative-date}.
+ */
+public class RelativeDateFormatter {
+  final static long SECOND_IN_MILLIS = 1000;
+
+  final static long MINUTE_IN_MILLIS = 60 * SECOND_IN_MILLIS;
+
+  final static long HOUR_IN_MILLIS = 60 * MINUTE_IN_MILLIS;
+
+  final static long DAY_IN_MILLIS = 24 * HOUR_IN_MILLIS;
+
+  final static long WEEK_IN_MILLIS = 7 * DAY_IN_MILLIS;
+
+  final static long MONTH_IN_MILLIS = 30 * DAY_IN_MILLIS;
+
+  final static long YEAR_IN_MILLIS = 365 * DAY_IN_MILLIS;
+
+  /**
+   * @param when {@link Date} to format
+   * @return age of given {@link Date} compared to now formatted in the same
+   *         relative format as returned by {@code git log --relative-date}
+   */
+  @SuppressWarnings("boxing")
+  public static String format(Date when) {
+    long ageMillis = (new Date()).getTime() - when.getTime();
+
+    // shouldn't happen in a perfect world
+    if (ageMillis < 0) return Util.C.inTheFuture();
+
+    // seconds
+    if (ageMillis < upperLimit(MINUTE_IN_MILLIS)) {
+      return Util.M.secondsAgo(round(ageMillis, SECOND_IN_MILLIS));
+    }
+
+    // minutes
+    if (ageMillis < upperLimit(HOUR_IN_MILLIS)) {
+      return Util.M.minutesAgo(round(ageMillis, MINUTE_IN_MILLIS));
+    }
+
+    // hours
+    if (ageMillis < upperLimit(DAY_IN_MILLIS)) {
+      return Util.M.hoursAgo(round(ageMillis, HOUR_IN_MILLIS));
+    }
+
+    // up to 14 days use days
+    if (ageMillis < 14 * DAY_IN_MILLIS) {
+      return Util.M.daysAgo(round(ageMillis, DAY_IN_MILLIS));
+    }
+
+    // up to 10 weeks use weeks
+    if (ageMillis < 10 * WEEK_IN_MILLIS) {
+      return Util.M.weeksAgo(round(ageMillis, WEEK_IN_MILLIS));
+    }
+
+    // months
+    if (ageMillis < YEAR_IN_MILLIS) {
+      return Util.M.monthsAgo(round(ageMillis, MONTH_IN_MILLIS));
+    }
+
+    // up to 5 years use "year, months" rounded to months
+    if (ageMillis < 5 * YEAR_IN_MILLIS) {
+      long years = ageMillis / YEAR_IN_MILLIS;
+      String yearLabel = (years > 1) ? Util.C.years() : Util.C.year();
+      long months = round(ageMillis % YEAR_IN_MILLIS, MONTH_IN_MILLIS);
+      String monthLabel =
+          (months > 1) ? Util.C.months() : (months == 1 ? Util.C.month() : "");
+      if (months == 0) {
+        return Util.M.years0MonthsAgo(years, yearLabel);
+      } else {
+        return Util.M.yearsMonthsAgo(years, yearLabel, months, monthLabel);
+      }
+    }
+
+    // years
+    return Util.M.yearsAgo(round(ageMillis, YEAR_IN_MILLIS));
+  }
+
+  private static long upperLimit(long unit) {
+    long limit = unit + unit / 2;
+    return limit;
+  }
+
+  private static long round(long n, long unit) {
+    long rounded = (n + unit / 2) / unit;
+    return rounded;
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
index fa2c5fd..06f0a4c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.java
@@ -33,6 +33,7 @@
   String reversePatchSetOrder();
   String showUsernameInReviewCategory();
   String buttonSaveChanges();
+  String showRelativeDateInChangeTable();
 
   String tabAccountSummary();
   String tabPreferences();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
index fd54363..b5d42c7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/AccountConstants.properties
@@ -14,6 +14,7 @@
 dateFormatLabel = Date/Time Format:
 contextWholeFile = Whole File
 buttonSaveChanges = Save Changes
+showRelativeDateInChangeTable = Show Relative Dates in Changes Table
 
 tabAccountSummary = Profile
 tabPreferences = Preferences
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
index c17a0aa..b745297 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/account/MyPreferencesScreen.java
@@ -42,6 +42,7 @@
   private CheckBox copySelfOnEmails;
   private CheckBox reversePatchSetOrder;
   private CheckBox showUsernameInReviewCategory;
+  private CheckBox relativeDateInChangeTable;
   private ListBox maximumPageSize;
   private ListBox dateFormat;
   private ListBox timeFormat;
@@ -94,7 +95,10 @@
       dateTimePanel.add(dateFormat);
       dateTimePanel.add(timeFormat);
     }
-    final Grid formGrid = new Grid(7, 2);
+
+    relativeDateInChangeTable = new CheckBox(Util.C.showRelativeDateInChangeTable());
+
+    final Grid formGrid = new Grid(8, 2);
 
     int row = 0;
     formGrid.setText(row, labelIdx, "");
@@ -125,6 +129,10 @@
     formGrid.setWidget(row, fieldIdx, dateTimePanel);
     row++;
 
+    formGrid.setText(row, labelIdx, "");
+    formGrid.setWidget(row, fieldIdx, relativeDateInChangeTable);
+    row++;
+
     add(formGrid);
 
     save = new Button(Util.C.buttonSaveChanges());
@@ -146,6 +154,7 @@
     e.listenTo(maximumPageSize);
     e.listenTo(dateFormat);
     e.listenTo(timeFormat);
+    e.listenTo(relativeDateInChangeTable);
   }
 
   @Override
@@ -167,6 +176,7 @@
     maximumPageSize.setEnabled(on);
     dateFormat.setEnabled(on);
     timeFormat.setEnabled(on);
+    relativeDateInChangeTable.setEnabled(on);
   }
 
   private void display(final AccountGeneralPreferences p) {
@@ -180,6 +190,7 @@
         p.getDateFormat());
     setListBox(timeFormat, AccountGeneralPreferences.TimeFormat.HHMM_12, //
         p.getTimeFormat());
+    relativeDateInChangeTable.setValue(p.isRelativeDateInChangeTable());
   }
 
   private void setListBox(final ListBox f, final short defaultValue,
@@ -243,6 +254,7 @@
     p.setTimeFormat(getListBox(timeFormat,
         AccountGeneralPreferences.TimeFormat.HHMM_12,
         AccountGeneralPreferences.TimeFormat.values()));
+    p.setRelativeDateInChangeTable(relativeDateInChangeTable.getValue());
 
     enable(false);
     save.setEnabled(false);
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
index 65b0634..b4ec733 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
@@ -171,4 +171,10 @@
 
   String diffAllSideBySide();
   String diffAllUnified();
+
+  String inTheFuture();
+  String month();
+  String months();
+  String year();
+  String years();
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
index a3719d3..5d92844 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
@@ -152,3 +152,9 @@
 
 diffAllSideBySide = All Side-by-Side
 diffAllUnified = All Unified
+
+inTheFuture = in the future
+month = month
+months = months
+years = years
+year = year
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
index ba46702..098fe07 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
@@ -56,4 +56,15 @@
   String groupIsNotAllowed(String group);
   String groupHasTooManyMembers(String group);
   String groupManyMembersConfirmation(String group, int memberCount);
+
+  String secondsAgo(long seconds);
+  String minutesAgo(long minutes);
+  String hoursAgo(long hours);
+  String daysAgo(long days);
+  String weeksAgo(long weeks);
+  String monthsAgo(long months);
+  String yearsAgo(long years);
+  String years0MonthsAgo(long years, String yearLabel);
+  String yearsMonthsAgo(long years, String yearLabel, long months,
+      String monthLabel);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
index ffa749d..6c99081 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
@@ -39,3 +39,13 @@
 groupIsNotAllowed =  The group {0} cannot be added as reviewer.
 groupHasTooManyMembers = The group {0} has too many members to add them all as reviewers.
 groupManyMembersConfirmation = The group {0} has {1} members. Do you want to add them all as reviewers?
+
+secondsAgo = {0} seconds ago
+minutesAgo = {0} minutes ago
+hoursAgo = {0} hours ago
+daysAgo = {0} days ago
+weeksAgo = {0} weeks ago
+monthsAgo = {0} months ago
+years0MonthsAgo = {0} {1} ago
+yearsMonthsAgo = {0} {1}, {2} {3} ago
+yearsAgo = {0} years ago
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable2.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable2.java
index 03cc11d..07f0c11 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable2.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable2.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.changes;
 
+import static com.google.gerrit.client.FormatUtil.relativeFormat;
 import static com.google.gerrit.client.FormatUtil.shortFormat;
 
 import com.google.gerrit.client.Gerrit;
@@ -206,7 +207,13 @@
         row, C_PROJECT, new ProjectLink(c.project_name_key(), c.status()));
     table.setWidget(row, C_BRANCH, new BranchLink(c.project_name_key(), c
         .status(), c.branch(), c.topic()));
-    table.setText(row, C_LAST_UPDATE, shortFormat(c.updated()));
+    if (Gerrit.isSignedIn()
+        && Gerrit.getUserAccount().getGeneralPreferences()
+            .isRelativeDateInChangeTable()) {
+      table.setText(row, C_LAST_UPDATE, relativeFormat(c.updated()));
+    } else {
+      table.setText(row, C_LAST_UPDATE, shortFormat(c.updated()));
+    }
 
     boolean displayName = Gerrit.isSignedIn() && Gerrit.getUserAccount()
         .getGeneralPreferences().isShowUsernameInReviewCategory();
diff --git a/gerrit-gwtui/src/test/java/com/google/gerrit/client/RelativeDateFormatterTest.java b/gerrit-gwtui/src/test/java/com/google/gerrit/client/RelativeDateFormatterTest.java
new file mode 100644
index 0000000..5be029c
--- /dev/null
+++ b/gerrit-gwtui/src/test/java/com/google/gerrit/client/RelativeDateFormatterTest.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.client;
+
+import static org.junit.Assert.assertEquals;
+import static com.google.gerrit.client.RelativeDateFormatter.YEAR_IN_MILLIS;
+import static com.google.gerrit.client.RelativeDateFormatter.SECOND_IN_MILLIS;
+import static com.google.gerrit.client.RelativeDateFormatter.MINUTE_IN_MILLIS;
+import static com.google.gerrit.client.RelativeDateFormatter.HOUR_IN_MILLIS;
+import static com.google.gerrit.client.RelativeDateFormatter.DAY_IN_MILLIS;
+
+import java.util.Date;
+
+import org.eclipse.jgit.util.RelativeDateFormatter;
+import org.junit.Test;
+
+public class RelativeDateFormatterTest {
+
+  private static void assertFormat(long ageFromNow, long timeUnit,
+      String expectedFormat) {
+    Date d = new Date(System.currentTimeMillis() - ageFromNow * timeUnit);
+    String s = RelativeDateFormatter.format(d);
+    assertEquals(expectedFormat, s);
+  }
+
+  @Test
+  public void testFuture() {
+    assertFormat(-100, YEAR_IN_MILLIS, "in the future");
+    assertFormat(-1, SECOND_IN_MILLIS, "in the future");
+  }
+
+  @Test
+  public void testFormatSeconds() {
+    assertFormat(1, SECOND_IN_MILLIS, "1 seconds ago");
+    assertFormat(89, SECOND_IN_MILLIS, "89 seconds ago");
+  }
+
+  @Test
+  public void testFormatMinutes() {
+    assertFormat(90, SECOND_IN_MILLIS, "2 minutes ago");
+    assertFormat(3, MINUTE_IN_MILLIS, "3 minutes ago");
+    assertFormat(60, MINUTE_IN_MILLIS, "60 minutes ago");
+    assertFormat(89, MINUTE_IN_MILLIS, "89 minutes ago");
+  }
+
+  @Test
+  public void testFormatHours() {
+    assertFormat(90, MINUTE_IN_MILLIS, "2 hours ago");
+    assertFormat(149, MINUTE_IN_MILLIS, "2 hours ago");
+    assertFormat(35, HOUR_IN_MILLIS, "35 hours ago");
+  }
+
+  @Test
+  public void testFormatDays() {
+    assertFormat(36, HOUR_IN_MILLIS, "2 days ago");
+    assertFormat(13, DAY_IN_MILLIS, "13 days ago");
+  }
+
+  @Test
+  public void testFormatWeeks() {
+    assertFormat(14, DAY_IN_MILLIS, "2 weeks ago");
+    assertFormat(69, DAY_IN_MILLIS, "10 weeks ago");
+  }
+
+  @Test
+  public void testFormatMonths() {
+    assertFormat(70, DAY_IN_MILLIS, "2 months ago");
+    assertFormat(75, DAY_IN_MILLIS, "3 months ago");
+    assertFormat(364, DAY_IN_MILLIS, "12 months ago");
+  }
+
+  @Test
+  public void testFormatYearsMonths() {
+    assertFormat(366, DAY_IN_MILLIS, "1 year ago");
+    assertFormat(380, DAY_IN_MILLIS, "1 year, 1 month ago");
+    assertFormat(410, DAY_IN_MILLIS, "1 year, 2 months ago");
+    assertFormat(2, YEAR_IN_MILLIS, "2 years ago");
+    assertFormat(1824, DAY_IN_MILLIS, "4 years, 12 months ago");
+  }
+
+  @Test
+  public void testFormatYears() {
+    assertFormat(5, YEAR_IN_MILLIS, "5 years ago");
+    assertFormat(60, YEAR_IN_MILLIS, "60 years ago");
+  }
+}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGeneralPreferences.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGeneralPreferences.java
index 6f121ee..23664bc 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGeneralPreferences.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGeneralPreferences.java
@@ -123,6 +123,9 @@
   @Column(id = 11)
   protected boolean showUsernameInReviewCategory;
 
+  @Column(id = 12)
+  protected boolean relativeDateInChangeTable;
+
   public AccountGeneralPreferences() {
   }
 
@@ -226,6 +229,14 @@
     timeFormat = fmt.name();
   }
 
+  public boolean isRelativeDateInChangeTable() {
+    return relativeDateInChangeTable;
+  }
+
+  public void setRelativeDateInChangeTable(final boolean relativeDateInChangeTable) {
+    this.relativeDateInChangeTable = relativeDateInChangeTable;
+  }
+
   public void resetToDefaults() {
     maximumPageSize = DEFAULT_PAGESIZE;
     showSiteHeader = true;
@@ -237,5 +248,6 @@
     downloadCommand = null;
     dateFormat = null;
     timeFormat = null;
+    relativeDateInChangeTable = false;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
index 4de3888..ea9245c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -32,7 +32,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_77> C = Schema_77.class;
+  public static final Class<Schema_78> C = Schema_78.class;
 
   public static class Module extends AbstractModule {
     @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_78.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_78.java
new file mode 100644
index 0000000..18ae8b4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_78.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class Schema_78 extends SchemaVersion {
+
+  @Inject
+  Schema_78(Provider<Schema_77> prior) {
+    super(prior);
+  }
+}