mirror of
https://github.com/scratchfoundation/scratch-www.git
synced 2024-11-27 09:35:56 -05:00
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:
parent
87d3d7845b
commit
6eb7504442
4 changed files with 295 additions and 5 deletions
114
src/lib/format-time.js
Normal file
114
src/lib/format-time.js
Normal 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;
|
||||
};
|
|
@ -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'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}
|
||||
|
|
|
@ -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: {
|
||||
|
|
167
test/unit/lib/format-time.test.js
Normal file
167
test/unit/lib/format-time.test.js
Normal 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');
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue