Initial time formatting pass. Does integer minutes, hours, days.

TODOS:
- set langauge appropriately
- do partial hours and days. e.g. 1 hour 5 minutes instead of just 1
hour.

Add a time formatting utility that formats a relative time in the future.
Use it in the mute modal & comment box.
This commit is contained in:
picklesrus 2020-11-12 14:21:23 -05:00
parent 87d3d7845b
commit 6eb7504442
4 changed files with 295 additions and 5 deletions

114
src/lib/format-time.js Normal file
View file

@ -0,0 +1,114 @@
/**
Given a timestamp in the future, calculate the largest, closest unit to show.
On the high end we stop at days. e.g. 56 days is still counted in days not months or weeks.
On the low end we stop at minutes.
@param {number} timeStamp A future time stamp in ms.
@returns {object} containing the unit (min, hours, days) and how many. e.g. {unit: minutes, duration: 3}
*/
const getTopLevelTimeUnit = timeStamp => {
const diff = timeStamp - Date.now();
const oneDayInMs = 1000 * 60 * 60 * 24;
const oneHourInMs = 1000 * 60 * 60;
const oneMinuteInMs = 1000 * 60;
let unit = 'minutes';
let duration = diff / oneMinuteInMs;
if (diff > oneDayInMs) {
unit = 'days';
duration = diff / oneDayInMs;
} else if (diff > oneHourInMs) {
unit = 'hours';
duration = diff / oneHourInMs;
}
return {
unit: unit,
duration: duration
};
};
/**
* This is used along with Intl.RelativeTimeFormat's formatToParts to extract
* the value from a given key.
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/formatToParts
* Also see examples in format-time.test.jsx.
* @param {string} key The name of the key you want to look up
* @param {Array} parts An array of parts from formatToParts.
* @returns {string} The value associated with the given key.
*/
const getValueFromKey = (key, parts) => {
for (const item of parts) {
if (item.type === key) {
return item.value;
}
}
// Just in case key doesn't exist.
return '';
};
/**
* Given a future timestamp and a langauge, constructs a phrase to describe that time relative to now.
* e.g. 2 days 3 hours, 3 minutes, 7 hours 38 minutes, 2 horas 5 minutos.
* The largest time unit is days, the smallest is minutes.
* @param {number} futureTime a timestamp in ms to build a phrase for.
* @param {string} lang Langauge to build the phrase in.
* @returns {string} A phrase representing the relative time in the future. e.g. 3 days 5 hours.
*/
module.exports.formatTimeUntil = (futureTime, lang) => {
const formatter = new Intl.RelativeTimeFormat(lang, {
localeMatcher: 'best fit',
numeric: 'always',
style: 'long'
});
const timeInfo = getTopLevelTimeUnit(futureTime);
let str = '';
const parts = formatter.formatToParts(timeInfo.duration,
timeInfo.unit);
// This shouldn't happen, but is here for extra safety.
if (parts.length === 0) {
return '';
}
// Extract the whole number from formatToParts list.
const topLevelValue = getValueFromKey('integer', parts);
const units = parts[parts.length - 1].value;
// Add value and unit to the string. e.g. 3 hours or 2 minutes
str = `${topLevelValue}${units}`;
// Convert the part after the decimal to a number so we can use it to calculate the next unit
const remainder = parseFloat(`.${getValueFromKey('fraction', parts)}`);
// The smallest unit we show is minutes so we can stop if we're already there
// or, our remainder is smaller than the next level down.
// e.g. if it is 1 hour 30 sec, we just show 1 hour or
// if it is 1 day 45 min, we only show 1 day.
if (timeInfo.unit === 'minutes' ||
(timeInfo.unit === 'hours' && remainder < 1.0 / 60) ||
(timeInfo.unit === 'days' && remainder < 1.0 / 24)) {
return str;
}
// Now we need to go figure out what the second part of the string is
// e.g. if we're 2 days 3 hours in the future, at this point we have "2 days"
// and need to figure out how many hours.
let remainingTime = 0;
let unitsOfRemainingTime = '';
if (timeInfo.unit === 'hours') {
remainingTime = remainder * 60;
unitsOfRemainingTime = 'minutes';
} else if (timeInfo.unit === 'days') {
remainingTime = remainder * 24;
unitsOfRemainingTime = 'hours';
}
const remainingParts = formatter.formatToParts(remainingTime, unitsOfRemainingTime);
// This shouldn't happen, but is here for extra safety.
if (remainingParts.length === 0) {
return str;
}
// Concatenate 2nd level value and unit. e.g. add "3 hours" to "1 day" to get "1 day 3 hours".
str = `${str}${getValueFromKey('integer', remainingParts)}${remainingParts[remainingParts.length - 1].value}`;
return str;
};

View file

@ -12,6 +12,7 @@ const InplaceInput = require('../../../components/forms/inplace-input.jsx');
const Button = require('../../../components/forms/button.jsx');
const CommentingStatus = require('../../../components/commenting-status/commenting-status.jsx');
const MuteModal = require('../../../components/modal/mute/modal.jsx');
const formatTime = require('../../../lib/format-time');
const connect = require('react-redux').connect;
@ -79,7 +80,7 @@ class ComposeComment extends React.Component {
let muteExpiresAt = 0;
let rejectedStatus = ComposeStatus.REJECTED;
if (body.status && body.status.mute_status) {
muteExpiresAt = body.status.mute_status.muteExpiresAt;
muteExpiresAt = body.status.mute_status.muteExpiresAt * 1000; // convert to ms
rejectedStatus = ComposeStatus.REJECTED_MUTE;
if (this.shouldShowMuteModal(body.status.mute_status.offenses)) {
muteOpen = true;
@ -165,9 +166,9 @@ class ComposeComment extends React.Component {
<CommentingStatus>
<p>Scratch thinks your comment was disrespectful.</p>
<p>
For the next {this.state.muteExpiresAt} you
For the next {formatTime.formatTimeUntil(this.state.muteExpiresAt, window._locale)} you
won&apos;t be able to post comments.
Once {this.state.muteExpiresAt} have passed,
Once {formatTime.formatTimeUntil(this.state.muteExpiresAt, window._locale)} have passed,
you will be able to comment again.
</p>
<p className="bottom-text">For more information,
@ -248,7 +249,7 @@ class ComposeComment extends React.Component {
useStandardSizes
className="mod-mute"
shouldCloseOnOverlayClick={false}
timeMuted={this.state.muteExpiresAt}
timeMuted={formatTime.formatTimeUntil(this.state.muteExpiresAt, window._locale)}
onRequestClose={this.handleMuteClose}
/>
) : null}

View file

@ -3,9 +3,9 @@ const {shallowWithIntl} = require('../../helpers/intl-helpers.jsx');
const ComposeComment = require('../../../src/views/preview/comment/compose-comment.jsx');
import configureStore from 'redux-mock-store';
describe('Compose Comment test', () => {
const mockStore = configureStore();
let _mockFormat;
const defaultProps = () =>({
user: {
thumbnailUrl: 'scratch.mit.edu',
@ -15,6 +15,14 @@ describe('Compose Comment test', () => {
let store;
beforeEach(() => {
const mockFormatToParts = {
formatToParts: jest.fn()
};
_mockFormat = Intl.RelativeTimeFormat = jest
.fn()
.mockImplementation(() => mockFormatToParts);
mockFormatToParts.formatToParts.mockReturnValue([]);
store = mockStore({
session: {
session: {

View file

@ -0,0 +1,167 @@
const format = require('../../../src/lib/format-time');
describe('unit test lib/format-time.js', () => {
let realDateNow;
let _mockFormat;
const mockFormatExpression = {
formatToParts: jest.fn()
};
beforeEach(() =>{
realDateNow = Date.now.bind(global.Date);
global.Date.now = () => 0;
_mockFormat = Intl.RelativeTimeFormat = jest
.fn()
.mockImplementation(() => mockFormatExpression);
});
afterEach(()=>{
global.Date.now = realDateNow;
jest.resetAllMocks();
});
test('test timestamp that is 2 minutes in the future', () => {
let response;
const twoMin = 2 * 60 * 1000;
const formatToPartsResponse = [
{type: 'literal', value: 'in'},
{type: 'integer', value: '2', unit: 'minute'},
{type: 'decimal', value: '.', unit: 'minute'},
{type: 'fraction', value: '119', unit: 'minute'},
{type: 'literal', value: 'minutes'}
];
mockFormatExpression.formatToParts.mockReturnValue(formatToPartsResponse);
response = format.formatTimeUntil(twoMin, 'en');
expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'minutes');
expect(response).toEqual('2minutes');
});
test('test timestamp that is 2 hours in the future', () => {
let response;
const twoHours = 2 * 60 * 60 * 1000;
const formatToPartsResponse = [
{type: 'literal', value: 'in'},
{type: 'integer', value: '2', unit: 'hours'},
{type: 'decimal', value: '.', unit: 'hours'},
{type: 'fraction', value: '0', unit: 'hours'},
{type: 'literal', value: 'hours'}
];
mockFormatExpression.formatToParts.mockReturnValue(formatToPartsResponse);
response = format.formatTimeUntil(twoHours, 'en');
expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'hours');
expect(response).toEqual('2hours');
});
test('test timestamp that is exactly 2 days in the future', () => {
let response;
const twoDays = 2 * 60 * 60 * 24 * 1000;
const formatToPartsResponse = [
{type: 'literal', value: 'in'},
{type: 'integer', value: '2', unit: 'days'},
{type: 'decimal', value: '.', unit: 'days'},
{type: 'fraction', value: '0', unit: 'days'},
{type: 'literal', value: 'days'}
];
mockFormatExpression.formatToParts.mockReturnValue(formatToPartsResponse);
response = format.formatTimeUntil(twoDays, 'en');
expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2, 'days');
expect(response).toEqual('2days');
});
test('test timestamp that is 2.5 days in the future', () => {
let response;
const twoDays = 2.5 * 60 * 60 * 24 * 1000;
const formatToPartsResponseTwoAndAHalfDays = [
{type: 'literal', value: 'in'},
{type: 'integer', value: '2', unit: 'days'},
{type: 'decimal', value: '.', unit: 'days'},
{type: 'fraction', value: '5', unit: 'days'},
{type: 'literal', value: 'days'}
];
const formatToPartsResponseTwelveHours = [
{type: 'literal', value: 'in'},
{type: 'integer', value: '12', unit: 'hours'},
{type: 'decimal', value: '.', unit: 'hours'},
{type: 'fraction', value: '0', unit: 'hours'},
{type: 'literal', value: 'hours'}
];
mockFormatExpression.formatToParts
.mockReturnValueOnce(formatToPartsResponseTwoAndAHalfDays)
.mockReturnValueOnce(formatToPartsResponseTwelveHours);
response = format.formatTimeUntil(twoDays, 'en');
expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(2.5, 'days');
expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(12, 'hours');
expect(response).toEqual('2days12hours');
});
test('test timestamp that is 3.5 hours in the future', () => {
let response;
const twoDays = 3.5 * 60 * 60 * 1000;
const formatToPartsResponseOne = [
{type: 'literal', value: 'in'},
{type: 'integer', value: '3', unit: 'hours'},
{type: 'decimal', value: '.', unit: 'hours'},
{type: 'fraction', value: '5', unit: 'hours'},
{type: 'literal', value: 'hours'}
];
const formatToPartsResponseTwo = [
{type: 'literal', value: 'in'},
{type: 'integer', value: '30', unit: 'minutes'},
{type: 'decimal', value: '.', unit: 'minutes'},
{type: 'fraction', value: '0', unit: 'minutes'},
{type: 'literal', value: 'minutes'}
];
mockFormatExpression.formatToParts
.mockReturnValueOnce(formatToPartsResponseOne)
.mockReturnValueOnce(formatToPartsResponseTwo);
response = format.formatTimeUntil(twoDays, 'en');
expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(3.5, 'hours');
expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(30, 'minutes');
expect(response).toEqual('3hours30minutes');
});
test('test timestamp that is 1 day and less than an hour in the future', () => {
let response;
const aDayand10Min = 1.007 * 60 * 60 * 24 * 1000;
const formatToPartsResponse = [
{type: 'literal', value: 'in'},
{type: 'integer', value: '1', unit: 'days'},
{type: 'decimal', value: '.', unit: 'days'},
{type: 'fraction', value: '0.007', unit: 'days'},
{type: 'literal', value: 'days'}
];
mockFormatExpression.formatToParts
.mockReturnValueOnce(formatToPartsResponse);
response = format.formatTimeUntil(aDayand10Min, 'en');
expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(1.007, 'days');
expect(response).toEqual('1days');
});
test('test timestamp that is hour and less than a minute in the future', () => {
let response;
const anHourAnd30Sec = 1.008 * 60 * 60 * 1000;
const formatToPartsResponse = [
{type: 'literal', value: 'in'},
{type: 'integer', value: '1', unit: 'hours'},
{type: 'decimal', value: '.', unit: 'hours'},
{type: 'fraction', value: '0.008', unit: 'hours'},
{type: 'literal', value: 'hours'}
];
mockFormatExpression.formatToParts
.mockReturnValueOnce(formatToPartsResponse);
response = format.formatTimeUntil(anHourAnd30Sec, 'en');
expect(mockFormatExpression.formatToParts).toHaveBeenCalledWith(1.008, 'hours');
expect(response).toEqual('1hours');
});
});