Unverified Commit 5b672230 authored by Lyza Gardner's avatar Lyza Gardner Committed by GitHub

Merge pull request #1283 from hypothesis/created-edited

Add edited timestamp to annotation headers
parents 4248f4bf 9f41b616
...@@ -24,6 +24,9 @@ function AnnotationHeader({ ...@@ -24,6 +24,9 @@ function AnnotationHeader({
}) { }) {
const annotationLink = annotation.links ? annotation.links.html : ''; const annotationLink = annotation.links ? annotation.links.html : '';
const replyPluralized = !replyCount || replyCount > 1 ? 'replies' : 'reply'; const replyPluralized = !replyCount || replyCount > 1 ? 'replies' : 'reply';
// NB: `created` and `updated` are strings, not `Date`s
const hasBeenEdited =
annotation.updated && annotation.created !== annotation.updated;
return ( return (
<header className="annotation-header"> <header className="annotation-header">
...@@ -34,13 +37,26 @@ function AnnotationHeader({ ...@@ -34,13 +37,26 @@ function AnnotationHeader({
{replyCount} {replyPluralized} {replyCount} {replyPluralized}
</a> </a>
</div> </div>
{!isEditing && annotation.updated && ( {!isEditing && annotation.created && (
<div className="annotation-header__timestamp"> <div className="annotation-header__timestamp">
<Timestamp {hasBeenEdited && (
className="annotation-header__timestamp-link" <span className="annotation-header__timestamp-edited">
href={annotationLink} (edited{' '}
timestamp={annotation.updated} <Timestamp
/> className="annotation-header__timestamp-edited-link"
href={annotationLink}
timestamp={annotation.updated}
/>
){' '}
</span>
)}
<span className="annotation-header__timestamp-created">
<Timestamp
className="annotation-header__timestamp-created-link"
href={annotationLink}
timestamp={annotation.created}
/>
</span>
</div> </div>
)} )}
</div> </div>
......
...@@ -62,19 +62,46 @@ describe('AnnotationHeader', () => { ...@@ -62,19 +62,46 @@ describe('AnnotationHeader', () => {
); );
}); });
describe('timestamp', () => { describe('timestamps', () => {
it('should render a timestamp if annotation has an `updated` value', () => { it('should render timestamp container element if annotation has a `created` value', () => {
const wrapper = createAnnotationHeader(); const wrapper = createAnnotationHeader();
const timestamp = wrapper.find(Timestamp); const timestamp = wrapper.find('.annotation-header__timestamp');
assert.isTrue(timestamp.exists()); assert.isTrue(timestamp.exists());
}); });
it('should not render a timestamp if annotation does not have an `updated` value', () => { it('should not render timestamp container if annotation does not have a `created` value', () => {
const wrapper = createAnnotationHeader({ const wrapper = createAnnotationHeader({
annotation: fixtures.newAnnotation(), annotation: fixtures.newAnnotation(),
}); });
const timestamp = wrapper.find(Timestamp); const timestamp = wrapper.find('.annotation-header__timestamp');
assert.isFalse(timestamp.exists());
});
it('should render edited timestamp if annotation has been edited', () => {
const annotation = fixtures.defaultAnnotation();
annotation.updated = '2018-05-10T20:18:56.613388+00:00';
const wrapper = createAnnotationHeader({
annotation: annotation,
});
const timestamp = wrapper
.find(Timestamp)
.filter('.annotation-header__timestamp-edited-link');
assert.isTrue(timestamp.exists());
});
it('should not render edited timestamp if annotation has not been edited', () => {
// Default annotation's created value is same as updated; as if the annotation
// has not been edited before
const wrapper = createAnnotationHeader({
annotation: fixtures.newAnnotation(),
});
const timestamp = wrapper
.find(Timestamp)
.filter('.annotation-header__timestamp-edited-link');
assert.isFalse(timestamp.exists()); assert.isFalse(timestamp.exists());
}); });
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
function defaultAnnotation() { function defaultAnnotation() {
return { return {
id: 'deadbeef', id: 'deadbeef',
created: '2015-05-10T20:18:56.613388+00:00',
document: { document: {
title: 'A special document', title: 'A special document',
}, },
......
...@@ -2,10 +2,10 @@ ...@@ -2,10 +2,10 @@
const time = require('../time'); const time = require('../time');
const minute = 60; const second = 1000;
const minute = second * 60;
const hour = minute * 60; const hour = minute * 60;
const day = hour * 24; const day = hour * 24;
const msPerSecond = 1000;
describe('sidebar.util.time', function() { describe('sidebar.util.time', function() {
let sandbox; let sandbox;
...@@ -41,7 +41,7 @@ describe('sidebar.util.time', function() { ...@@ -41,7 +41,7 @@ describe('sidebar.util.time', function() {
// - A user in the UK who views the annotation will see “Jan 1” // - A user in the UK who views the annotation will see “Jan 1”
// on the annotation card (correct) // on the annotation card (correct)
// - A user in San Francisco who views the annotation will see // - A user in San Francisco who views the annotation will see
// “Dec 31st 2018" on the annotation card (also correct from // “Dec 31, 2018" on the annotation card (also correct from
// their point of view). // their point of view).
const date = new Date(isoString); const date = new Date(isoString);
date.getFullYear = sinon.stub().returns(date.getUTCFullYear()); date.getFullYear = sinon.stub().returns(date.getUTCFullYear());
...@@ -58,11 +58,11 @@ describe('sidebar.util.time', function() { ...@@ -58,11 +58,11 @@ describe('sidebar.util.time', function() {
[ [
{ now: '1970-01-01T00:00:10.000Z', text: 'Just now' }, { now: '1970-01-01T00:00:10.000Z', text: 'Just now' },
{ now: '1970-01-01T00:00:29.000Z', text: 'Just now' }, { now: '1970-01-01T00:00:29.000Z', text: 'Just now' },
{ now: '1970-01-01T00:00:49.000Z', text: '49 secs' }, { now: '1970-01-01T00:00:49.000Z', text: '49 secs ago' },
{ now: '1970-01-01T00:01:05.000Z', text: '1 min' }, { now: '1970-01-01T00:01:05.000Z', text: '1 min ago' },
{ now: '1970-01-01T00:03:05.000Z', text: '3 mins' }, { now: '1970-01-01T00:03:05.000Z', text: '3 mins ago' },
{ now: '1970-01-01T01:00:00.000Z', text: '1 hr' }, { now: '1970-01-01T01:00:00.000Z', text: '1 hr ago' },
{ now: '1970-01-01T04:00:00.000Z', text: '4 hrs' }, { now: '1970-01-01T04:00:00.000Z', text: '4 hrs ago' },
].forEach(test => { ].forEach(test => {
it('creates correct fuzzy string for fixture ' + test.now, () => { it('creates correct fuzzy string for fixture ' + test.now, () => {
const timeStamp = fakeDate('1970-01-01T00:00:00.000Z'); const timeStamp = fakeDate('1970-01-01T00:00:00.000Z');
...@@ -158,15 +158,15 @@ describe('sidebar.util.time', function() { ...@@ -158,15 +158,15 @@ describe('sidebar.util.time', function() {
const date = new Date().toISOString(); const date = new Date().toISOString();
const callback = sandbox.stub(); const callback = sandbox.stub();
time.decayingInterval(date, callback); time.decayingInterval(date, callback);
sandbox.clock.tick(6 * msPerSecond); sandbox.clock.tick(6 * second);
assert.calledWith(callback, date); assert.calledWith(callback, date);
sandbox.clock.tick(6 * msPerSecond); sandbox.clock.tick(6 * second);
assert.calledTwice(callback); assert.calledTwice(callback);
}); });
it('uses a longer delay for older timestamps', function() { it('uses a longer delay for older timestamps', function() {
const date = new Date().toISOString(); const date = new Date().toISOString();
const ONE_MINUTE = minute * msPerSecond; const ONE_MINUTE = minute;
sandbox.clock.tick(10 * ONE_MINUTE); sandbox.clock.tick(10 * ONE_MINUTE);
const callback = sandbox.stub(); const callback = sandbox.stub();
time.decayingInterval(date, callback); time.decayingInterval(date, callback);
...@@ -183,13 +183,13 @@ describe('sidebar.util.time', function() { ...@@ -183,13 +183,13 @@ describe('sidebar.util.time', function() {
const callback = sandbox.stub(); const callback = sandbox.stub();
const cancel = time.decayingInterval(date, callback); const cancel = time.decayingInterval(date, callback);
cancel(); cancel();
sandbox.clock.tick(minute * msPerSecond); sandbox.clock.tick(minute);
assert.notCalled(callback); assert.notCalled(callback);
}); });
it('does not set a timeout for dates > 24hrs ago', function() { it('does not set a timeout for dates > 24hrs ago', function() {
const date = new Date().toISOString(); const date = new Date().toISOString();
const ONE_DAY = day * msPerSecond; const ONE_DAY = day;
sandbox.clock.tick(10 * ONE_DAY); sandbox.clock.tick(10 * ONE_DAY);
const callback = sandbox.stub(); const callback = sandbox.stub();
...@@ -208,9 +208,9 @@ describe('sidebar.util.time', function() { ...@@ -208,9 +208,9 @@ describe('sidebar.util.time', function() {
}); });
[ [
{ now: '1970-01-01T00:00:10.000Z', expectedUpdateTime: 5 }, // we have a minimum of 5 secs { now: '1970-01-01T00:00:10.000Z', expectedUpdateTime: 5 * second }, // we have a minimum of 5 secs
{ now: '1970-01-01T00:00:20.000Z', expectedUpdateTime: 5 }, { now: '1970-01-01T00:00:20.000Z', expectedUpdateTime: 5 * second },
{ now: '1970-01-01T00:00:49.000Z', expectedUpdateTime: 5 }, { now: '1970-01-01T00:00:49.000Z', expectedUpdateTime: 5 * second },
{ now: '1970-01-01T00:01:05.000Z', expectedUpdateTime: minute }, { now: '1970-01-01T00:01:05.000Z', expectedUpdateTime: minute },
{ now: '1970-01-01T00:03:05.000Z', expectedUpdateTime: minute }, { now: '1970-01-01T00:03:05.000Z', expectedUpdateTime: minute },
{ now: '1970-01-01T04:00:00.000Z', expectedUpdateTime: hour }, { now: '1970-01-01T04:00:00.000Z', expectedUpdateTime: hour },
......
'use strict'; 'use strict';
const minute = 60; /**
const hour = minute * 60; * Utility functions for generating formatted "fuzzy" date strings and
* computing decaying intervals for updating those dates in a UI.
function lessThanThirtySecondsAgo(date, now) { */
return now - date < 30 * 1000;
}
function lessThanOneMinuteAgo(date, now) {
return now - date < 60 * 1000;
}
function lessThanOneHourAgo(date, now) {
return now - date < 60 * 60 * 1000;
}
function lessThanOneDayAgo(date, now) {
return now - date < 24 * 60 * 60 * 1000;
}
function thisYear(date, now) {
return date.getFullYear() === now.getFullYear();
}
function delta(date, now) {
return Math.round((now - date) / 1000);
}
function nSec(date, now) {
return '{} secs'.replace('{}', Math.floor(delta(date, now)));
}
function nMin(date, now) {
const n = Math.floor(delta(date, now) / minute);
let template = '{} min';
if (n > 1) {
template = template + 's';
}
return template.replace('{}', n);
}
function nHr(date, now) {
const n = Math.floor(delta(date, now) / hour);
let template = '{} hr';
if (n > 1) {
template = template + 's';
}
return template.replace('{}', n); const SECOND = 1000;
} const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
// Cached DateTimeFormat instances, // Cached DateTimeFormat instances,
// because instantiating a DateTimeFormat is expensive. // because instantiating a DateTimeFormat is expensive.
...@@ -63,18 +19,32 @@ let formatters = {}; ...@@ -63,18 +19,32 @@ let formatters = {};
function clearFormatters() { function clearFormatters() {
formatters = {}; formatters = {};
} }
/** /**
* Efficiently return `date` formatted with `options`. * Calculate time delta in milliseconds between two `Date` objects
* *
* This is a wrapper for Intl.DateTimeFormat.format() that caches * @param {Date} date
* DateTimeFormat instances because they're expensive to create. * @param {Date} now
* Calling Date.toLocaleDateString() lots of times is also expensive in some */
function delta(date, now) {
return now - date;
}
/**
* Efficiently return date string formatted with `options`.
*
* This is a wrapper for `Intl.DateTimeFormat.format()` that caches
* `DateTimeFormat` instances because they're expensive to create.
* Calling `Date.toLocaleDateString()` lots of times is also expensive in some
* browsers as it appears to create a new formatter for each call. * browsers as it appears to create a new formatter for each call.
* *
* @param {Date} date
* @param {Object} options - Options for `Intl.DateTimeFormat.format()`
* @param {Object} Intl - JS internationalization API implementation; this
* param is present for dependency injection during test.
* @returns {string} * @returns {string}
*
*/ */
function format(date, options, Intl) { function formatIntl(date, options, Intl) {
// If the tests have passed in a mock Intl then use it, otherwise use the // If the tests have passed in a mock Intl then use it, otherwise use the
// real one. // real one.
if (typeof Intl === 'undefined') { if (typeof Intl === 'undefined') {
...@@ -96,12 +66,36 @@ function format(date, options, Intl) { ...@@ -96,12 +66,36 @@ function format(date, options, Intl) {
} }
} }
/**
* Date templating functions.
*
* @param {Date} date
* @param {Date} now
* @return {String} formatted date
*/
function nSec(date, now) {
const n = Math.floor(delta(date, now) / SECOND);
return `${n} secs ago`;
}
function nMin(date, now) {
const n = Math.floor(delta(date, now) / MINUTE);
const plural = n > 1 ? 's' : '';
return `${n} min${plural} ago`;
}
function nHr(date, now) {
const n = Math.floor(delta(date, now) / HOUR);
const plural = n > 1 ? 's' : '';
return `${n} hr${plural} ago`;
}
function dayAndMonth(date, now, Intl) { function dayAndMonth(date, now, Intl) {
return format(date, { month: 'short', day: 'numeric' }, Intl); return formatIntl(date, { month: 'short', day: 'numeric' }, Intl);
} }
function dayAndMonthAndYear(date, now, Intl) { function dayAndMonthAndYear(date, now, Intl) {
return format( return formatIntl(
date, date,
{ day: 'numeric', month: 'short', year: 'numeric' }, { day: 'numeric', month: 'short', year: 'numeric' },
Intl Intl
...@@ -110,37 +104,39 @@ function dayAndMonthAndYear(date, now, Intl) { ...@@ -110,37 +104,39 @@ function dayAndMonthAndYear(date, now, Intl) {
const BREAKPOINTS = [ const BREAKPOINTS = [
{ {
test: lessThanThirtySecondsAgo, // Less than 30 seconds
format: function() { test: (date, now) => delta(date, now) < 30 * SECOND,
return 'Just now'; formatFn: () => 'Just now',
}, nextUpdate: 1 * SECOND,
nextUpdate: 1,
}, },
{ {
test: lessThanOneMinuteAgo, // Less than 1 minute
format: nSec, test: (date, now) => delta(date, now) < 1 * MINUTE,
nextUpdate: 1, formatFn: nSec,
nextUpdate: 1 * SECOND,
}, },
{ {
test: lessThanOneHourAgo, // less than one hour
format: nMin, test: (date, now) => delta(date, now) < 1 * HOUR,
nextUpdate: minute, formatFn: nMin,
nextUpdate: 1 * MINUTE,
}, },
{ {
test: lessThanOneDayAgo, // less than one day
format: nHr, test: (date, now) => delta(date, now) < 24 * HOUR,
nextUpdate: hour, formatFn: nHr,
nextUpdate: 1 * HOUR,
}, },
{ {
test: thisYear, // this year
format: dayAndMonth, test: (date, now) => date.getFullYear() === now.getFullYear(),
formatFn: dayAndMonth,
nextUpdate: null, nextUpdate: null,
}, },
{ {
test: function() { // everything else (default case)
return true; test: () => true,
}, formatFn: dayAndMonthAndYear,
format: dayAndMonthAndYear,
nextUpdate: null, nextUpdate: null,
}, },
]; ];
...@@ -151,40 +147,47 @@ const BREAKPOINTS = [ ...@@ -151,40 +147,47 @@ const BREAKPOINTS = [
* *
* @param {Date} date - The date to consider as the timestamp to format. * @param {Date} date - The date to consider as the timestamp to format.
* @param {Date} now - The date to consider as the current time. * @param {Date} now - The date to consider as the current time.
* @return {breakpoint} A dict that describes how to format the date. * @return {breakpoint|null} An object that describes how to format the date or
* null if no breakpoint matches.
*/ */
function getBreakpoint(date, now) { function getBreakpoint(date, now) {
let breakpoint; for (let breakpoint of BREAKPOINTS) {
for (let i = 0; i < BREAKPOINTS.length; i++) {
breakpoint = BREAKPOINTS[i];
if (breakpoint.test(date, now)) { if (breakpoint.test(date, now)) {
return breakpoint; return breakpoint;
} }
} }
return null; return null;
} }
/**
* Return the number of milliseconds until the next update for a given date
* should be handled, based on the delta between `date` and `now`.
*
* @param {Date} date
* @param {Date} now
* @return {Number|null} - ms until next update or `null` if no update
* should occur
*/
function nextFuzzyUpdate(date, now) { function nextFuzzyUpdate(date, now) {
if (!date) { if (!date) {
return null; return null;
} }
let secs = getBreakpoint(date, now).nextUpdate; let nextUpdate = getBreakpoint(date, now).nextUpdate;
if (secs === null) { if (nextUpdate === null) {
return null; return null;
} }
// We don't want to refresh anything more often than 5 seconds // We don't want to refresh anything more often than 5 seconds
secs = Math.max(secs, 5); nextUpdate = Math.max(nextUpdate, 5 * SECOND);
// setTimeout limit is MAX_INT32=(2^31-1) (in ms), // setTimeout limit is MAX_INT32=(2^31-1) (in ms),
// which is about 24.8 days. So we don't set up any timeouts // which is about 24.8 days. So we don't set up any timeouts
// longer than 24 days, that is, 2073600 seconds. // longer than 24 days, that is, 2073600 seconds.
secs = Math.min(secs, 2073600); nextUpdate = Math.min(nextUpdate, 2073600 * SECOND);
return secs; return nextUpdate;
} }
/** /**
...@@ -195,48 +198,55 @@ function nextFuzzyUpdate(date, now) { ...@@ -195,48 +198,55 @@ function nextFuzzyUpdate(date, now) {
* update frequency depends on the age of a timestamp. * update frequency depends on the age of a timestamp.
* *
* @param {String} date - An ISO 8601 date string timestamp to format. * @param {String} date - An ISO 8601 date string timestamp to format.
* @param {Date} callback - A callback function to call when the timestamp changes. * @param {UpdateCallback} callback - A callback function to call when the timestamp changes.
* @return {Function} A function that cancels the automatic refresh. * @return {Function} A function that cancels the automatic refresh.
*/ */
function decayingInterval(date, callback) { function decayingInterval(date, callback) {
let timer; let timer;
const timeStamp = date ? new Date(date) : null; const timeStamp = date ? new Date(date) : null;
const update = function() {
const update = () => {
const fuzzyUpdate = nextFuzzyUpdate(timeStamp, new Date()); const fuzzyUpdate = nextFuzzyUpdate(timeStamp, new Date());
if (fuzzyUpdate === null) { if (fuzzyUpdate === null) {
return; return;
} }
const nextUpdate = 1000 * fuzzyUpdate + 500; const nextUpdate = fuzzyUpdate + 500;
timer = setTimeout(function() { timer = setTimeout(() => {
callback(date); callback(date);
update(); update();
}, nextUpdate); }, nextUpdate);
}; };
update(); update();
return function() { return () => clearTimeout(timer);
clearTimeout(timer);
};
} }
/**
* This callback is a param for the `decayingInterval` function.
* @callback UpdateCallback
* @param {Date} - The date associated with the current interval/timeout
*/
/** /**
* Formats a date as a string relative to the current date. * Formats a date as a string relative to the current date.
* *
* @param {Date} date - The date to consider as the timestamp to format. * @param {Date} date - The date to consider as the timestamp to format.
* @param {Date} now - The date to consider as the current time. * @param {Date} now - The date to consider as the current time.
* @param {Object} Intl - JS internationalization API implementation. * @param {Object} Intl - JS internationalization API implementation; this
* param is present for dependency injection during test.
* @return {string} A 'fuzzy' string describing the relative age of the date. * @return {string} A 'fuzzy' string describing the relative age of the date.
*/ */
function toFuzzyString(date, now, Intl) { function toFuzzyString(date, now, Intl) {
if (!date) { if (!date) {
return ''; return '';
} }
return getBreakpoint(date, now).format(date, now, Intl); return getBreakpoint(date, now).formatFn(date, now, Intl);
} }
module.exports = { module.exports = {
clearFormatters: clearFormatters, clearFormatters: clearFormatters, // For testing
decayingInterval: decayingInterval, decayingInterval: decayingInterval,
nextFuzzyUpdate: nextFuzzyUpdate, nextFuzzyUpdate: nextFuzzyUpdate, // For testing
toFuzzyString: toFuzzyString, toFuzzyString: toFuzzyString,
}; };
...@@ -10,12 +10,29 @@ ...@@ -10,12 +10,29 @@
align-items: baseline; align-items: baseline;
} }
// Timestamps are right aligned in a flex row
&__timestamp { &__timestamp {
margin-left: auto; margin-left: auto;
} }
&__timestamp-link { &__timestamp-edited {
@include font-small; @include font-small;
font-style: italic;
color: $grey-4;
}
&__timestamp-created-link,
&__timestamp-edited-link {
&:hover {
text-decoration: underline;
}
}
&__timestamp-created-link {
color: $grey-5;
}
&__timestamp-edited-link {
color: $grey-4; color: $grey-4;
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment