diff --git a/services/aur/aur.service.js b/services/aur/aur.service.js index ee8381f7245c1..bcba06c1f24ff 100644 --- a/services/aur/aur.service.js +++ b/services/aur/aur.service.js @@ -1,10 +1,8 @@ import Joi from 'joi' -import { - floorCount as floorCountColor, - age as ageColor, -} from '../color-formatters.js' +import { renderDateBadge } from '../date.js' +import { floorCount as floorCountColor } from '../color-formatters.js' import { renderLicenseBadge } from '../licenses.js' -import { metric, formatDate } from '../text-formatters.js' +import { metric } from '../text-formatters.js' import { nonNegativeInteger } from '../validators.js' import { BaseJsonService, @@ -243,16 +241,10 @@ class AurLastModified extends BaseAurService { static defaultBadgeData = { label: 'last modified' } - static render({ date }) { - const color = ageColor(date) - const message = formatDate(date) - return { color, message } - } - async handle({ packageName }) { const json = await this.fetch({ packageName }) const date = 1000 * parseInt(json.results[0].LastModified) - return this.constructor.render({ date }) + return renderDateBadge(date) } } diff --git a/services/bitbucket/bitbucket-last-commit.service.js b/services/bitbucket/bitbucket-last-commit.service.js index ca6f4c241b5d1..45a60ffb07585 100644 --- a/services/bitbucket/bitbucket-last-commit.service.js +++ b/services/bitbucket/bitbucket-last-commit.service.js @@ -1,7 +1,6 @@ import Joi from 'joi' -import { age as ageColor } from '../color-formatters.js' +import { renderDateBadge } from '../date.js' import { BaseJsonService, NotFound, pathParam, queryParam } from '../index.js' -import { formatDate } from '../text-formatters.js' import { relativeUri } from '../validators.js' const schema = Joi.object({ @@ -43,13 +42,6 @@ export default class BitbucketLastCommit extends BaseJsonService { static defaultBadgeData = { label: 'last commit' } - static render({ commitDate }) { - return { - message: formatDate(commitDate), - color: ageColor(Date.parse(commitDate)), - } - } - async fetch({ user, repo, branch, path }) { // https://developer.atlassian.com/cloud/bitbucket/rest/api-group-commits/#api-repositories-workspace-repo-slug-commits-get return this._requestJson({ @@ -76,6 +68,6 @@ export default class BitbucketLastCommit extends BaseJsonService { if (!commit) throw new NotFound({ prettyMessage: 'no commits found' }) - return this.constructor.render({ commitDate: commit.date }) + return renderDateBadge(commit.date) } } diff --git a/services/chrome-web-store/chrome-web-store-last-updated.service.js b/services/chrome-web-store/chrome-web-store-last-updated.service.js index 6bea2500e7caf..533074c8740d2 100644 --- a/services/chrome-web-store/chrome-web-store-last-updated.service.js +++ b/services/chrome-web-store/chrome-web-store-last-updated.service.js @@ -1,5 +1,4 @@ -import { age } from '../color-formatters.js' -import { formatDate } from '../text-formatters.js' +import { renderDateBadge } from '../date.js' import { NotFound, pathParams } from '../index.js' import BaseChromeWebStoreService from './chrome-web-store-base.js' @@ -31,11 +30,6 @@ export default class ChromeWebStoreLastUpdated extends BaseChromeWebStoreService throw new NotFound({ prettyMessage: 'not found' }) } - const lastUpdatedDate = Date.parse(lastUpdated) - - return { - message: formatDate(lastUpdatedDate), - color: age(lastUpdatedDate), - } + return renderDateBadge(lastUpdated) } } diff --git a/services/color-formatters.js b/services/color-formatters.js index 968aa2b2f9894..f0892a76db666 100644 --- a/services/color-formatters.js +++ b/services/color-formatters.js @@ -6,7 +6,6 @@ */ import pep440 from '@renovatebot/pep440' -import dayjs from 'dayjs' /** * Determines the color used for a badge based on version. @@ -175,24 +174,7 @@ function colorScale(steps, colors, reversed) { } } -/** - * Determines the color used for a badge according to the age. - * Age is calculated as days elapsed till current date. - * The color varies from bright green to red as the age increases - * or the other way around if `reverse` is given `true`. - * - * @param {string} date Date string - * @param {boolean} reversed Reverse the color scale a.k.a. the older, the better - * @returns {string} Badge color - */ -function age(date, reversed = false) { - const colorByAge = colorScale([7, 30, 180, 365, 730], undefined, !reversed) - const daysElapsed = dayjs().diff(dayjs(date), 'days') - return colorByAge(daysElapsed) -} - export { - age, colorScale, coveragePercentage, downloadCount, diff --git a/services/color-formatters.spec.js b/services/color-formatters.spec.js index 6921b0d90803e..735e07c3398c9 100644 --- a/services/color-formatters.spec.js +++ b/services/color-formatters.spec.js @@ -1,7 +1,6 @@ import { expect } from 'chai' import { forCases, given, test } from 'sazerac' import { - age, colorScale, coveragePercentage, letterScore, @@ -53,46 +52,6 @@ describe('Color formatters', function () { given('Z').expect('red') }) - const monthsAgo = months => { - const result = new Date() - // This looks wack but it works. - result.setMonth(result.getMonth() - months) - return result - } - test(age, () => { - given(Date.now()) - .describe('when given the current timestamp') - .expect('brightgreen') - given(new Date()) - .describe('when given the current Date') - .expect('brightgreen') - given(new Date(2001, 1, 1)) - .describe('when given a Date many years ago') - .expect('red') - given(monthsAgo(2)) - .describe('when given a Date two months ago') - .expect('yellowgreen') - given(monthsAgo(15)) - .describe('when given a Date 15 months ago') - .expect('orange') - // --- reversed --- // - given(Date.now(), true) - .describe('when given the current timestamp and reversed') - .expect('red') - given(new Date(), true) - .describe('when given the current Date and reversed') - .expect('red') - given(new Date(2001, 1, 1), true) - .describe('when given a Date many years ago and reversed') - .expect('brightgreen') - given(monthsAgo(2), true) - .describe('when given a Date two months ago and reversed') - .expect('yellow') - given(monthsAgo(15), true) - .describe('when given a Date 15 months ago and reversed') - .expect('green') - }) - test(version, () => { forCases([given('1.0'), given(9), given(1.0)]).expect('blue') diff --git a/services/date.js b/services/date.js new file mode 100644 index 0000000000000..f727c773d5cf6 --- /dev/null +++ b/services/date.js @@ -0,0 +1,108 @@ +/** + * Commonly-used functions for rendering badges containing a date + * + * @module + */ + +import dayjs from 'dayjs' +import calendar from 'dayjs/plugin/calendar.js' +import customParseFormat from 'dayjs/plugin/customParseFormat.js' +import { colorScale } from './color-formatters.js' +import { InvalidResponse } from './index.js' + +dayjs.extend(calendar) +dayjs.extend(customParseFormat) + +/** + * Parse and validate a string date into a dayjs object. Use this helper + * in preference to invoking dayjs directly when parsing a date from string. + * + * @param {...any} args - Variadic: Arguments to pass through to dayjs + * @returns {dayjs} - Parsed object + * @throws {InvalidResponse} - Error if validation fails + * @see https://day.js.org/docs/en/parse/string + * @see https://day.js.org/docs/en/parse/string-format + * @see https://day.js.org/docs/en/parse/is-valid + * @example + * parseDate('2024-01-01') + * parseDate('31/01/2024', 'DD/MM/YYYY') + * parseDate('2018 Enero 15', 'YYYY MMMM DD', 'es') + */ +function parseDate(...args) { + let date + if (args.length >= 2) { + // always use strict mode if format arg is supplied + date = dayjs(...args, true) + } else { + date = dayjs(...args) + } + if (!date.isValid()) { + throw new InvalidResponse({ prettyMessage: 'invalid date' }) + } + return date +} + +/** + * Returns a formatted date string without the year based on the value of input date param d. + * + * @param {Date | string | number | dayjs } d JS Date object, string, unix timestamp or dayjs object + * @returns {string} Formatted date string + */ +function formatDate(d) { + const date = parseDate(d) + const dateString = date.calendar(null, { + lastDay: '[yesterday]', + sameDay: '[today]', + lastWeek: '[last] dddd', + sameElse: 'MMMM YYYY', + }) + // Trim current year from date string + return dateString.replace(` ${dayjs().year()}`, '').toLowerCase() +} + +/** + * Determines the color used for a badge according to the age. + * Age is calculated as days elapsed till current date. + * The color varies from bright green to red as the age increases + * or the other way around if `reverse` is given `true`. + * + * @param {Date | string | number | dayjs } date JS Date object, string, unix timestamp or dayjs object + * @param {boolean} reversed Reverse the color scale (the older, the better) + * @returns {string} Badge color + */ +function age(date, reversed = false) { + const colorByAge = colorScale([7, 30, 180, 365, 730], undefined, !reversed) + const daysElapsed = dayjs().diff(parseDate(date), 'days') + return colorByAge(daysElapsed) +} + +/** + * Creates a badge object that displays a date + * + * @param {Date | string | number | dayjs } date JS Date object, string, unix timestamp or dayjs object + * @param {boolean} reversed Reverse the color scale (the older, the better) + * @returns {object} A badge object that has two properties: message, and color + */ +function renderDateBadge(date, reversed = false) { + const d = parseDate(date) + const color = age(d, reversed) + const message = formatDate(d) + return { message, color } +} + +/** + * Returns a relative date from the input timestamp. + * For example, day after tomorrow's timestamp will return 'in 2 days'. + * + * @param {number | string} timestamp - Unix timestamp + * @returns {string} Relative date from the unix timestamp + */ +function formatRelativeDate(timestamp) { + const parsedDate = dayjs.unix(parseInt(timestamp, 10)) + if (!parsedDate.isValid()) { + return 'invalid date' + } + return dayjs().to(parsedDate).toLowerCase() +} + +export { parseDate, renderDateBadge, formatDate, formatRelativeDate, age } diff --git a/services/date.spec.js b/services/date.spec.js new file mode 100644 index 0000000000000..fc74268b3c692 --- /dev/null +++ b/services/date.spec.js @@ -0,0 +1,132 @@ +import { expect } from 'chai' +import { test, given } from 'sazerac' +import sinon from 'sinon' +import { parseDate, formatDate, formatRelativeDate, age } from './date.js' +import { InvalidResponse } from './index.js' + +describe('parseDate', function () { + it('parses valid inputs', function () { + expect(parseDate('2024-01-01').valueOf()).to.equal( + new Date('2024-01-01').valueOf(), + ) + expect(parseDate('Jan 01 01:00:00 2024 GMT').valueOf()).to.equal( + new Date('2024-01-01T01:00:00.000Z').valueOf(), + ) + expect(parseDate('31/01/2024', 'DD/MM/YYYY').valueOf()).to.equal( + new Date('2024-01-31T00:00:00.000Z').valueOf(), + ) + }) + + it('throws when given invalid inputs', function () { + // not a date + expect(() => parseDate('foo')).to.throw(InvalidResponse) + expect(() => parseDate([])).to.throw(InvalidResponse) + expect(() => parseDate(null)).to.throw(InvalidResponse) + + // invalid dates (only works with format string) + expect(() => parseDate('2024-02-31', 'YYYY-MM-DD')).to.throw( + InvalidResponse, + ) + expect(() => parseDate('2024-12-32', 'YYYY-MM-DD')).to.throw( + InvalidResponse, + ) + + // non-standard format with no format string + expect(() => parseDate('31/01/2024')).to.throw(InvalidResponse) + + // parse format doesn't match date + expect(() => parseDate('2024-01-01', 'YYYYMMDDHHmmss')).to.throw( + InvalidResponse, + ) + }) + + test(formatDate, () => { + given(1465513200000) + .describe('when given a timestamp in june 2016') + .expect('june 2016') + }) + + context('in october', function () { + let clock + beforeEach(function () { + clock = sinon.useFakeTimers(new Date(2017, 9, 15).getTime()) + }) + afterEach(function () { + clock.restore() + }) + + test(formatDate, () => { + given(new Date(2017, 0, 1).getTime()) + .describe('when given the beginning of this year') + .expect('january') + }) + }) + + context('in october', function () { + let clock + beforeEach(function () { + clock = sinon.useFakeTimers(new Date(2018, 9, 29).getTime()) + }) + afterEach(function () { + clock.restore() + }) + + test(formatRelativeDate, () => { + given(new Date(2018, 9, 31).getTime() / 1000) + .describe('when given the end of october') + .expect('in 2 days') + }) + + test(formatRelativeDate, () => { + given(new Date(2018, 9, 1).getTime() / 1000) + .describe('when given the beginning of october') + .expect('a month ago') + }) + + test(formatRelativeDate, () => { + given(9999999999999) + .describe('when given invalid date') + .expect('invalid date') + }) + }) + + const monthsAgo = months => { + const result = new Date() + // This looks wack but it works. + result.setMonth(result.getMonth() - months) + return result + } + test(age, () => { + given(Date.now()) + .describe('when given the current timestamp') + .expect('brightgreen') + given(new Date()) + .describe('when given the current Date') + .expect('brightgreen') + given(new Date(2001, 1, 1)) + .describe('when given a Date many years ago') + .expect('red') + given(monthsAgo(2)) + .describe('when given a Date two months ago') + .expect('yellowgreen') + given(monthsAgo(15)) + .describe('when given a Date 15 months ago') + .expect('orange') + // --- reversed --- // + given(Date.now(), true) + .describe('when given the current timestamp and reversed') + .expect('red') + given(new Date(), true) + .describe('when given the current Date and reversed') + .expect('red') + given(new Date(2001, 1, 1), true) + .describe('when given a Date many years ago and reversed') + .expect('brightgreen') + given(monthsAgo(2), true) + .describe('when given a Date two months ago and reversed') + .expect('yellow') + given(monthsAgo(15), true) + .describe('when given a Date 15 months ago and reversed') + .expect('green') + }) +}) diff --git a/services/date/date.service.js b/services/date/date.service.js index 84830b71dffd4..11e6fa3cb2dd3 100644 --- a/services/date/date.service.js +++ b/services/date/date.service.js @@ -1,4 +1,4 @@ -import { formatRelativeDate } from '../text-formatters.js' +import { formatRelativeDate } from '../date.js' import { BaseService, pathParams } from '../index.js' const description = ` diff --git a/services/eclipse-marketplace/eclipse-marketplace-update.service.js b/services/eclipse-marketplace/eclipse-marketplace-update.service.js index 247dbda264cc6..ab16e77086d3d 100644 --- a/services/eclipse-marketplace/eclipse-marketplace-update.service.js +++ b/services/eclipse-marketplace/eclipse-marketplace-update.service.js @@ -1,7 +1,6 @@ import Joi from 'joi' import { pathParams } from '../index.js' -import { formatDate } from '../text-formatters.js' -import { age as ageColor } from '../color-formatters.js' +import { renderDateBadge } from '../date.js' import { nonNegativeInteger } from '../validators.js' import EclipseMarketplaceBase from './eclipse-marketplace-base.js' @@ -34,19 +33,12 @@ export default class EclipseMarketplaceUpdate extends EclipseMarketplaceBase { static defaultBadgeData = { label: 'updated' } - static render({ date }) { - return { - message: formatDate(date), - color: ageColor(date), - } - } - async handle({ name }) { const { marketplace } = await this.fetch({ name, schema: updateResponseSchema, }) const date = 1000 * parseInt(marketplace.node.changed) - return this.constructor.render({ date }) + return renderDateBadge(date) } } diff --git a/services/factorio-mod-portal/factorio-mod-portal.service.js b/services/factorio-mod-portal/factorio-mod-portal.service.js index c7ea96ac9489c..f8ab8eadea741 100644 --- a/services/factorio-mod-portal/factorio-mod-portal.service.js +++ b/services/factorio-mod-portal/factorio-mod-portal.service.js @@ -1,7 +1,6 @@ import Joi from 'joi' import { BaseJsonService, pathParams } from '../index.js' -import { age } from '../color-formatters.js' -import { formatDate } from '../text-formatters.js' +import { renderDateBadge } from '../date.js' import { nonNegativeInteger } from '../validators.js' import { renderDownloadsBadge } from '../downloads.js' import { renderVersionBadge } from '../version.js' @@ -131,18 +130,9 @@ class FactorioModPortalLastUpdated extends BaseFactorioModPortalService { static defaultBadgeData = { label: 'last updated' } - static render({ lastUpdated }) { - return { - message: formatDate(lastUpdated), - color: age(lastUpdated), - } - } - async handle({ modName }) { const resp = await this.fetch({ modName }) - return this.constructor.render({ - lastUpdated: resp.latest_release.released_at, - }) + return renderDateBadge(resp.latest_release.released_at) } } diff --git a/services/galaxytoolshed/galaxytoolshed-activity.service.js b/services/galaxytoolshed/galaxytoolshed-activity.service.js index 4389f2fd54aa5..c0f7cc087bbc0 100644 --- a/services/galaxytoolshed/galaxytoolshed-activity.service.js +++ b/services/galaxytoolshed/galaxytoolshed-activity.service.js @@ -1,5 +1,5 @@ import { pathParams } from '../index.js' -import { formatDate } from '../text-formatters.js' +import { renderDateBadge } from '../date.js' import BaseGalaxyToolshedService from './galaxytoolshed-base.js' export default class GalaxyToolshedCreatedDate extends BaseGalaxyToolshedService { @@ -29,11 +29,6 @@ export default class GalaxyToolshedCreatedDate extends BaseGalaxyToolshedService static defaultBadgeData = { label: 'created date', - color: 'blue', - } - - static render({ date }) { - return { message: formatDate(date) } } async handle({ repository, owner }) { @@ -42,6 +37,6 @@ export default class GalaxyToolshedCreatedDate extends BaseGalaxyToolshedService owner, }) const { create_time: date } = response[0] - return this.constructor.render({ date }) + return renderDateBadge(date, true) } } diff --git a/services/gitea/gitea-last-commit.service.js b/services/gitea/gitea-last-commit.service.js index 38c0eb44bf747..edf33a06645fe 100644 --- a/services/gitea/gitea-last-commit.service.js +++ b/services/gitea/gitea-last-commit.service.js @@ -1,7 +1,6 @@ import Joi from 'joi' -import { age as ageColor } from '../color-formatters.js' +import { renderDateBadge } from '../date.js' import { pathParam, queryParam } from '../index.js' -import { formatDate } from '../text-formatters.js' import { optionalUrl, relativeUri } from '../validators.js' import GiteaBase from './gitea-base.js' import { description, httpErrorsFor } from './gitea-helper.js' @@ -114,13 +113,6 @@ export default class GiteaLastCommit extends GiteaBase { static defaultBadgeData = { label: 'last commit' } - static render({ commitDate }) { - return { - message: formatDate(commitDate), - color: ageColor(Date.parse(commitDate)), - } - } - async fetch({ user, repo, branch, baseUrl, path }) { // https://gitea.com/api/swagger#/repository return super.fetch({ @@ -146,8 +138,6 @@ export default class GiteaLastCommit extends GiteaBase { baseUrl, path, }) - return this.constructor.render({ - commitDate: body[0].commit[displayTimestamp].date, - }) + return renderDateBadge(body[0].commit[displayTimestamp].date) } } diff --git a/services/github/gist/github-gist-last-commit.service.js b/services/github/gist/github-gist-last-commit.service.js index 8b67181f74326..d090c9d3f2c36 100644 --- a/services/github/gist/github-gist-last-commit.service.js +++ b/services/github/gist/github-gist-last-commit.service.js @@ -1,7 +1,6 @@ import Joi from 'joi' import { pathParams } from '../../index.js' -import { formatDate } from '../../text-formatters.js' -import { age as ageColor } from '../../color-formatters.js' +import { renderDateBadge } from '../../date.js' import { GithubAuthV3Service } from '../github-auth-service.js' import { documentation, httpErrorsFor } from '../github-helpers.js' @@ -27,13 +26,6 @@ export default class GistLastCommit extends GithubAuthV3Service { static defaultBadgeData = { label: 'last commit' } - static render({ commitDate }) { - return { - message: formatDate(commitDate), - color: ageColor(Date.parse(commitDate)), - } - } - async fetch({ gistId }) { return this._requestJson({ url: `/gists/${gistId}`, @@ -44,6 +36,6 @@ export default class GistLastCommit extends GithubAuthV3Service { async handle({ gistId }) { const { updated_at: commitDate } = await this.fetch({ gistId }) - return this.constructor.render({ commitDate }) + return renderDateBadge(commitDate) } } diff --git a/services/github/github-created-at.service.js b/services/github/github-created-at.service.js index 2245bd0e010eb..0e4c654dbb2ce 100644 --- a/services/github/github-created-at.service.js +++ b/services/github/github-created-at.service.js @@ -1,8 +1,6 @@ -import dayjs from 'dayjs' import Joi from 'joi' -import { age } from '../color-formatters.js' +import { renderDateBadge } from '../date.js' import { pathParams } from '../index.js' -import { formatDate } from '../text-formatters.js' import { GithubAuthV3Service } from './github-auth-service.js' import { documentation, httpErrorsFor } from './github-helpers.js' @@ -34,14 +32,6 @@ export default class GithubCreatedAt extends GithubAuthV3Service { static defaultBadgeData = { label: 'created at' } - static render({ createdAt }) { - const date = dayjs(createdAt) - return { - message: formatDate(date), - color: age(date, true), - } - } - async handle({ user, repo }) { const { created_at: createdAt } = await this._requestJson({ schema, @@ -49,6 +39,6 @@ export default class GithubCreatedAt extends GithubAuthV3Service { httpErrors: httpErrorsFor('repo not found'), }) - return this.constructor.render({ createdAt }) + return renderDateBadge(createdAt, true) } } diff --git a/services/github/github-hacktoberfest.service.js b/services/github/github-hacktoberfest.service.js index 6c9e6d343fc59..45a94f03b27ff 100644 --- a/services/github/github-hacktoberfest.service.js +++ b/services/github/github-hacktoberfest.service.js @@ -2,6 +2,7 @@ import gql from 'graphql-tag' import Joi from 'joi' import dayjs from 'dayjs' import { pathParam, queryParam } from '../index.js' +import { parseDate } from '../date.js' import { metric, maybePluralize } from '../text-formatters.js' import { nonNegativeInteger } from '../validators.js' import { GithubAuthV4Service } from './github-auth-service.js' @@ -97,7 +98,7 @@ export default class GithubHacktoberfestCombinedStatus extends GithubAuthV4Servi // The global cutoff time is 11/1 noon UTC. // /~https://github.com/badges/shields/pull/4109#discussion_r330782093 // We want to show "1 day left" on the last day so we add 1. - daysLeft = dayjs(`${year}-11-01 12:00:00 Z`).diff(dayjs(), 'days') + 1 + daysLeft = parseDate(`${year}-11-01 12:00:00 Z`).diff(dayjs(), 'days') + 1 } if (daysLeft < 0) { return { @@ -181,7 +182,10 @@ export default class GithubHacktoberfestCombinedStatus extends GithubAuthV4Servi } static getCalendarPosition(year) { - const daysToStart = dayjs(`${year}-10-01 00:00:00 Z`).diff(dayjs(), 'days') + const daysToStart = parseDate(`${year}-10-01 00:00:00 Z`).diff( + dayjs(), + 'days', + ) const isBefore = daysToStart > 0 return { daysToStart, isBefore } } diff --git a/services/github/github-issue-detail.service.js b/services/github/github-issue-detail.service.js index f6fb5039ef4b0..5d3f2b1692c4f 100644 --- a/services/github/github-issue-detail.service.js +++ b/services/github/github-issue-detail.service.js @@ -1,7 +1,7 @@ import Joi from 'joi' import { nonNegativeInteger } from '../validators.js' -import { formatDate, metric } from '../text-formatters.js' -import { age } from '../color-formatters.js' +import { metric } from '../text-formatters.js' +import { renderDateBadge } from '../date.js' import { InvalidResponse, pathParams } from '../index.js' import { GithubAuthV3Service } from './github-auth-service.js' import { @@ -133,11 +133,13 @@ const ageUpdateMap = { }).required(), transform: ({ json, property }) => property === 'age' ? json.created_at : json.updated_at, - render: ({ property, value }) => ({ - color: age(value), - label: property === 'age' ? 'created' : 'updated', - message: formatDate(value), - }), + render: ({ property, value }) => { + const label = property === 'age' ? 'created' : 'updated' + return { + ...renderDateBadge(value), + label, + } + }, } const milestoneMap = { diff --git a/services/github/github-issue-detail.spec.js b/services/github/github-issue-detail.spec.js index 3db55856dd3f3..92103c412e754 100644 --- a/services/github/github-issue-detail.spec.js +++ b/services/github/github-issue-detail.spec.js @@ -1,7 +1,7 @@ import { expect } from 'chai' import { test, given } from 'sazerac' -import { age } from '../color-formatters.js' -import { formatDate, metric } from '../text-formatters.js' +import { age, formatDate } from '../date.js' +import { metric } from '../text-formatters.js' import { InvalidResponse } from '../index.js' import GithubIssueDetail from './github-issue-detail.service.js' import { issueStateColor, commentsColor } from './github-helpers.js' diff --git a/services/github/github-last-commit.service.js b/services/github/github-last-commit.service.js index fa989229c22b1..bc90ac914b741 100644 --- a/services/github/github-last-commit.service.js +++ b/services/github/github-last-commit.service.js @@ -1,7 +1,6 @@ import Joi from 'joi' -import { age as ageColor } from '../color-formatters.js' +import { renderDateBadge } from '../date.js' import { NotFound, pathParam, queryParam } from '../index.js' -import { formatDate } from '../text-formatters.js' import { relativeUri } from '../validators.js' import { GithubAuthV3Service } from './github-auth-service.js' import { documentation, httpErrorsFor } from './github-helpers.js' @@ -88,13 +87,6 @@ export default class GithubLastCommit extends GithubAuthV3Service { static defaultBadgeData = { label: 'last commit' } - static render({ commitDate }) { - return { - message: formatDate(commitDate), - color: ageColor(Date.parse(commitDate)), - } - } - async fetch({ user, repo, branch, path }) { return this._requestJson({ url: `/repos/${user}/${repo}/commits`, @@ -111,8 +103,6 @@ export default class GithubLastCommit extends GithubAuthV3Service { if (!commit) throw new NotFound({ prettyMessage: 'no commits found' }) - return this.constructor.render({ - commitDate: commit[displayTimestamp].date, - }) + return renderDateBadge(commit[displayTimestamp].date) } } diff --git a/services/github/github-release-date.service.js b/services/github/github-release-date.service.js index 011b94d053c1e..633421c3e334b 100644 --- a/services/github/github-release-date.service.js +++ b/services/github/github-release-date.service.js @@ -1,8 +1,6 @@ -import dayjs from 'dayjs' import Joi from 'joi' import { pathParam, queryParam } from '../index.js' -import { age } from '../color-formatters.js' -import { formatDate } from '../text-formatters.js' +import { renderDateBadge } from '../date.js' import { GithubAuthV3Service } from './github-auth-service.js' import { documentation, httpErrorsFor } from './github-helpers.js' @@ -63,14 +61,6 @@ export default class GithubReleaseDate extends GithubAuthV3Service { static defaultBadgeData = { label: 'release date' } - static render({ date }) { - const releaseDate = dayjs(date) - return { - message: formatDate(releaseDate), - color: age(releaseDate), - } - } - async fetch({ variant, user, repo }) { const url = variant === 'release-date' @@ -86,10 +76,8 @@ export default class GithubReleaseDate extends GithubAuthV3Service { async handle({ variant, user, repo }, queryParams) { const body = await this.fetch({ variant, user, repo }) if (Array.isArray(body)) { - return this.constructor.render({ - date: body[0][queryParams.display_date], - }) + return renderDateBadge(body[0][queryParams.display_date]) } - return this.constructor.render({ date: body[queryParams.display_date] }) + return renderDateBadge(body[queryParams.display_date]) } } diff --git a/services/gitlab/gitlab-last-commit.service.js b/services/gitlab/gitlab-last-commit.service.js index b97c300bebbd5..3f44197bfd4e2 100644 --- a/services/gitlab/gitlab-last-commit.service.js +++ b/services/gitlab/gitlab-last-commit.service.js @@ -1,7 +1,6 @@ import Joi from 'joi' -import { age as ageColor } from '../color-formatters.js' +import { renderDateBadge } from '../date.js' import { NotFound, pathParam, queryParam } from '../index.js' -import { formatDate } from '../text-formatters.js' import { optionalUrl, relativeUri } from '../validators.js' import GitLabBase from './gitlab-base.js' import { description, httpErrorsFor } from './gitlab-helper.js' @@ -66,13 +65,6 @@ export default class GitlabLastCommit extends GitLabBase { static defaultBadgeData = { label: 'last commit' } - static render({ commitDate }) { - return { - message: formatDate(commitDate), - color: ageColor(Date.parse(commitDate)), - } - } - async fetch({ project, baseUrl, ref, path }) { // https://docs.gitlab.com/ee/api/commits.html#list-repository-commits return super.fetch({ @@ -94,6 +86,6 @@ export default class GitlabLastCommit extends GitLabBase { if (!commit) throw new NotFound({ prettyMessage: 'no commits found' }) - return this.constructor.render({ commitDate: commit.committed_date }) + return renderDateBadge(commit.committed_date) } } diff --git a/services/maven-central/maven-central-last-update.service.js b/services/maven-central/maven-central-last-update.service.js index 86f48d301dc02..6632de4fade96 100644 --- a/services/maven-central/maven-central-last-update.service.js +++ b/services/maven-central/maven-central-last-update.service.js @@ -1,12 +1,8 @@ import Joi from 'joi' -import customParseFormat from 'dayjs/plugin/customParseFormat.js' -import dayjs from 'dayjs' -import { InvalidResponse, pathParams } from '../index.js' +import { pathParams } from '../index.js' +import { parseDate, renderDateBadge } from '../date.js' import { nonNegativeInteger } from '../validators.js' -import { formatDate } from '../text-formatters.js' -import { age as ageColor } from '../color-formatters.js' import MavenCentralBase from './maven-central-base.js' -dayjs.extend(customParseFormat) const updateResponseSchema = Joi.object({ metadata: Joi.object({ @@ -38,13 +34,6 @@ export default class MavenCentralLastUpdate extends MavenCentralBase { static defaultBadgeData = { label: 'last updated' } - static render({ date }) { - return { - message: formatDate(date), - color: ageColor(date), - } - } - async handle({ groupId, artifactId }) { const { metadata } = await this.fetch({ groupId, @@ -52,15 +41,11 @@ export default class MavenCentralLastUpdate extends MavenCentralBase { schema: updateResponseSchema, }) - const date = dayjs( + const date = parseDate( String(metadata.versioning.lastUpdated), 'YYYYMMDDHHmmss', ) - if (!date.isValid) { - throw new InvalidResponse({ prettyMessage: 'invalid date' }) - } - - return this.constructor.render({ date }) + return renderDateBadge(date) } } diff --git a/services/npm/npm-last-update.service.js b/services/npm/npm-last-update.service.js index 5f3ded018bea0..2d99105dee8ea 100644 --- a/services/npm/npm-last-update.service.js +++ b/services/npm/npm-last-update.service.js @@ -1,8 +1,6 @@ import Joi from 'joi' -import dayjs from 'dayjs' -import { InvalidResponse, NotFound, pathParam, queryParam } from '../index.js' -import { formatDate } from '../text-formatters.js' -import { age as ageColor } from '../color-formatters.js' +import { NotFound, pathParam, queryParam } from '../index.js' +import { renderDateBadge } from '../date.js' import NpmBase, { packageNameDescription, queryParamSchema, @@ -21,26 +19,17 @@ const abbreviatedSchema = Joi.object({ modified: Joi.string().required(), }).required() -class NpmLastUpdateBase extends NpmBase { +export class NpmLastUpdateWithTag extends NpmBase { static category = 'activity' - static defaultBadgeData = { label: 'last updated' } - - static render({ date }) { - return { - message: formatDate(date), - color: ageColor(date), - } - } -} - -export class NpmLastUpdateWithTag extends NpmLastUpdateBase { static route = { base: 'npm/last-update', pattern: ':scope(@[^/]+)?/:packageName/:tag', queryParamSchema, } + static defaultBadgeData = { label: 'last updated' } + static openApi = { '/npm/last-update/{packageName}/{tag}': { get: { @@ -81,19 +70,17 @@ export class NpmLastUpdateWithTag extends NpmLastUpdateBase { throw new NotFound({ prettyMessage: 'tag not found' }) } - const date = dayjs(packageData.time[tagVersion]) - - if (!date.isValid) { - throw new InvalidResponse({ prettyMessage: 'invalid date' }) - } - - return this.constructor.render({ date }) + return renderDateBadge(packageData.time[tagVersion]) } } -export class NpmLastUpdate extends NpmLastUpdateBase { +export class NpmLastUpdate extends NpmBase { + static category = 'activity' + static route = this.buildRoute('npm/last-update', { withTag: false }) + static defaultBadgeData = { label: 'last updated' } + static openApi = { '/npm/last-update/{packageName}': { get: { @@ -127,12 +114,6 @@ export class NpmLastUpdate extends NpmLastUpdateBase { abbreviated: true, }) - const date = dayjs(packageData.modified) - - if (!date.isValid) { - throw new InvalidResponse({ prettyMessage: 'invalid date' }) - } - - return this.constructor.render({ date }) + return renderDateBadge(packageData.modified) } } diff --git a/services/open-vsx/open-vsx-release-date.service.js b/services/open-vsx/open-vsx-release-date.service.js index 8d00bba7e573c..511c33de1aeb5 100644 --- a/services/open-vsx/open-vsx-release-date.service.js +++ b/services/open-vsx/open-vsx-release-date.service.js @@ -1,6 +1,5 @@ import { pathParams } from '../index.js' -import { age } from '../color-formatters.js' -import { formatDate } from '../text-formatters.js' +import { renderDateBadge } from '../date.js' import { OpenVSXBase, description } from './open-vsx-base.js' export default class OpenVSXReleaseDate extends OpenVSXBase { @@ -32,17 +31,8 @@ export default class OpenVSXReleaseDate extends OpenVSXBase { static defaultBadgeData = { label: 'release date' } - static render({ releaseDate }) { - return { - message: formatDate(releaseDate), - color: age(releaseDate), - } - } - async handle({ namespace, extension }) { const { timestamp } = await this.fetch({ namespace, extension }) - return this.constructor.render({ - releaseDate: timestamp, - }) + return renderDateBadge(timestamp) } } diff --git a/services/snapcraft/snapcraft-last-update.service.js b/services/snapcraft/snapcraft-last-update.service.js index 379ef8ddef596..d57d21ac77c12 100644 --- a/services/snapcraft/snapcraft-last-update.service.js +++ b/services/snapcraft/snapcraft-last-update.service.js @@ -1,8 +1,6 @@ import Joi from 'joi' -import dayjs from 'dayjs' -import { pathParams, queryParam, NotFound, InvalidResponse } from '../index.js' -import { formatDate } from '../text-formatters.js' -import { age as ageColor } from '../color-formatters.js' +import { pathParams, queryParam, NotFound } from '../index.js' +import { renderDateBadge } from '../date.js' import SnapcraftBase, { snapcraftPackageParam } from './snapcraft-base.js' const queryParamSchema = Joi.object({ @@ -57,13 +55,6 @@ export default class SnapcraftLastUpdate extends SnapcraftBase { }, } - static render({ lastUpdatedDate }) { - return { - message: formatDate(lastUpdatedDate), - color: ageColor(lastUpdatedDate), - } - } - static transform(apiData, track, risk, arch) { const channelMap = apiData['channel-map'] let filteredChannelMap = channelMap.filter( @@ -99,12 +90,6 @@ export default class SnapcraftLastUpdate extends SnapcraftBase { arch, ) - const lastUpdatedDate = dayjs(channel['released-at']) - - if (!lastUpdatedDate.isValid) { - throw new InvalidResponse({ prettyMessage: 'invalid date' }) - } - - return this.constructor.render({ lastUpdatedDate }) + return renderDateBadge(channel['released-at']) } } diff --git a/services/sourceforge/sourceforge-last-commit.service.js b/services/sourceforge/sourceforge-last-commit.service.js index b275b93ceaa08..91d25bfe6c33c 100644 --- a/services/sourceforge/sourceforge-last-commit.service.js +++ b/services/sourceforge/sourceforge-last-commit.service.js @@ -1,7 +1,6 @@ import Joi from 'joi' import { BaseJsonService, pathParams } from '../index.js' -import { formatDate } from '../text-formatters.js' -import { age as ageColor } from '../color-formatters.js' +import { renderDateBadge } from '../date.js' const schema = Joi.object({ commits: Joi.array() @@ -35,13 +34,6 @@ export default class SourceforgeLastCommit extends BaseJsonService { static defaultBadgeData = { label: 'last commit' } - static render({ commitDate }) { - return { - message: formatDate(new Date(commitDate)), - color: ageColor(new Date(commitDate)), - } - } - async fetch({ project }) { return this._requestJson({ url: `https://sourceforge.net/rest/p/${project}/git/commits`, @@ -54,8 +46,6 @@ export default class SourceforgeLastCommit extends BaseJsonService { async handle({ project }) { const body = await this.fetch({ project }) - return this.constructor.render({ - commitDate: body.commits[0].committed_date, - }) + return renderDateBadge(body.commits[0].committed_date) } } diff --git a/services/steam/steam-workshop.service.js b/services/steam/steam-workshop.service.js index e2301b8231357..cfbc02a97c031 100644 --- a/services/steam/steam-workshop.service.js +++ b/services/steam/steam-workshop.service.js @@ -1,8 +1,8 @@ import Joi from 'joi' import prettyBytes from 'pretty-bytes' +import { renderDateBadge } from '../date.js' import { renderDownloadsBadge } from '../downloads.js' -import { metric, formatDate } from '../text-formatters.js' -import { age as ageColor } from '../color-formatters.js' +import { metric } from '../text-formatters.js' import { NotFound, pathParams } from '../index.js' import BaseSteamAPI from './steam-base.js' @@ -242,13 +242,9 @@ class SteamFileReleaseDate extends SteamFileService { label: 'release date', } - static render({ releaseDate }) { - return { message: formatDate(releaseDate), color: ageColor(releaseDate) } - } - async onRequest({ response }) { const releaseDate = new Date(0).setUTCSeconds(response.time_created) - return this.constructor.render({ releaseDate }) + return renderDateBadge(releaseDate) } } @@ -277,13 +273,9 @@ class SteamFileUpdateDate extends SteamFileService { label: 'update date', } - static render({ updateDate }) { - return { message: formatDate(updateDate), color: ageColor(updateDate) } - } - async onRequest({ response }) { const updateDate = new Date(0).setUTCSeconds(response.time_updated) - return this.constructor.render({ updateDate }) + return renderDateBadge(updateDate) } } diff --git a/services/text-formatters.js b/services/text-formatters.js index 185170e023148..95521a3b29dde 100644 --- a/services/text-formatters.js +++ b/services/text-formatters.js @@ -5,12 +5,6 @@ * @module */ -import dayjs from 'dayjs' -import calendar from 'dayjs/plugin/calendar.js' -import relativeTime from 'dayjs/plugin/relativeTime.js' -dayjs.extend(calendar) -dayjs.extend(relativeTime) - /** * Creates a string of stars and empty stars based on the rating. * The number of stars is determined by the integer part of the rating. @@ -165,39 +159,6 @@ function maybePluralize(singular, countable, plural) { } } -/** - * Returns a formatted date string without the year based on the value of input date param d. - * - * @param {Date | string | number | object } d - Input date in dayjs compatible format, date object, datestring, Unix timestamp etc. - * @returns {string} Formatted date string - */ -function formatDate(d) { - const date = dayjs(d) - const dateString = date.calendar(null, { - lastDay: '[yesterday]', - sameDay: '[today]', - lastWeek: '[last] dddd', - sameElse: 'MMMM YYYY', - }) - // Trim current year from date string - return dateString.replace(` ${dayjs().year()}`, '').toLowerCase() -} - -/** - * Returns a relative date from the input timestamp. - * For example, day after tomorrow's timestamp will return 'in 2 days'. - * - * @param {number | string} timestamp - Unix timestamp - * @returns {string} Relative date from the unix timestamp - */ -function formatRelativeDate(timestamp) { - const parsedDate = dayjs.unix(parseInt(timestamp, 10)) - if (!parsedDate.isValid()) { - return 'invalid date' - } - return dayjs().to(parsedDate).toLowerCase() -} - export { starRating, currencyFromCode, @@ -206,6 +167,4 @@ export { omitv, addv, maybePluralize, - formatDate, - formatRelativeDate, } diff --git a/services/text-formatters.spec.js b/services/text-formatters.spec.js index 8dedef13a32be..9bdbd843ecbef 100644 --- a/services/text-formatters.spec.js +++ b/services/text-formatters.spec.js @@ -1,5 +1,4 @@ import { test, given } from 'sazerac' -import sinon from 'sinon' import { starRating, currencyFromCode, @@ -8,8 +7,6 @@ import { omitv, addv, maybePluralize, - formatDate, - formatRelativeDate, } from './text-formatters.js' describe('Text formatters', function () { @@ -113,54 +110,4 @@ describe('Text formatters', function () { given('box', [123, 456], 'boxes').expect('boxes') given('box', undefined, 'boxes').expect('boxes') }) - - test(formatDate, () => { - given(1465513200000) - .describe('when given a timestamp in june 2016') - .expect('june 2016') - }) - - context('in october', function () { - let clock - beforeEach(function () { - clock = sinon.useFakeTimers(new Date(2017, 9, 15).getTime()) - }) - afterEach(function () { - clock.restore() - }) - - test(formatDate, () => { - given(new Date(2017, 0, 1).getTime()) - .describe('when given the beginning of this year') - .expect('january') - }) - }) - - context('in october', function () { - let clock - beforeEach(function () { - clock = sinon.useFakeTimers(new Date(2018, 9, 29).getTime()) - }) - afterEach(function () { - clock.restore() - }) - - test(formatRelativeDate, () => { - given(new Date(2018, 9, 31).getTime() / 1000) - .describe('when given the end of october') - .expect('in 2 days') - }) - - test(formatRelativeDate, () => { - given(new Date(2018, 9, 1).getTime() / 1000) - .describe('when given the beginning of october') - .expect('a month ago') - }) - - test(formatRelativeDate, () => { - given(9999999999999) - .describe('when given invalid date') - .expect('invalid date') - }) - }) }) diff --git a/services/vaadin-directory/vaadin-directory-release-date.service.js b/services/vaadin-directory/vaadin-directory-release-date.service.js index 1756c6a9e10b1..6a435d19b25ba 100644 --- a/services/vaadin-directory/vaadin-directory-release-date.service.js +++ b/services/vaadin-directory/vaadin-directory-release-date.service.js @@ -1,6 +1,5 @@ import { pathParams } from '../index.js' -import { formatDate } from '../text-formatters.js' -import { age as ageColor } from '../color-formatters.js' +import { renderDateBadge } from '../date.js' import { BaseVaadinDirectoryService } from './vaadin-directory-base.js' export default class VaadinDirectoryReleaseDate extends BaseVaadinDirectoryService { @@ -27,14 +26,8 @@ export default class VaadinDirectoryReleaseDate extends BaseVaadinDirectoryServi label: 'latest release date', } - static render({ date }) { - return { message: formatDate(date), color: ageColor(date) } - } - async handle({ alias, packageName }) { const data = await this.fetch({ packageName }) - return this.constructor.render({ - date: data.latestAvailableRelease.publicationDate, - }) + return renderDateBadge(data.latestAvailableRelease.publicationDate) } } diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-last-updated.service.js b/services/visual-studio-marketplace/visual-studio-marketplace-last-updated.service.js index 137d5dcff1118..f205954ab458a 100644 --- a/services/visual-studio-marketplace/visual-studio-marketplace-last-updated.service.js +++ b/services/visual-studio-marketplace/visual-studio-marketplace-last-updated.service.js @@ -1,6 +1,5 @@ import { pathParams } from '../index.js' -import { age } from '../color-formatters.js' -import { formatDate } from '../text-formatters.js' +import { renderDateBadge } from '../date.js' import VisualStudioMarketplaceBase from './visual-studio-marketplace-base.js' export default class VisualStudioMarketplaceLastUpdated extends VisualStudioMarketplaceBase { @@ -28,13 +27,6 @@ export default class VisualStudioMarketplaceLastUpdated extends VisualStudioMark label: 'last updated', } - static render({ lastUpdated }) { - return { - message: formatDate(lastUpdated), - color: age(lastUpdated), - } - } - transform({ json }) { const { extension } = this.transformExtension({ json }) const lastUpdated = extension.lastUpdated @@ -44,6 +36,6 @@ export default class VisualStudioMarketplaceLastUpdated extends VisualStudioMark async handle({ extensionId }) { const json = await this.fetch({ extensionId }) const { lastUpdated } = this.transform({ json }) - return this.constructor.render({ lastUpdated }) + return renderDateBadge(lastUpdated) } } diff --git a/services/visual-studio-marketplace/visual-studio-marketplace-release-date.service.js b/services/visual-studio-marketplace/visual-studio-marketplace-release-date.service.js index 1200b2e538610..010319715a9ee 100644 --- a/services/visual-studio-marketplace/visual-studio-marketplace-release-date.service.js +++ b/services/visual-studio-marketplace/visual-studio-marketplace-release-date.service.js @@ -1,6 +1,5 @@ import { pathParams } from '../index.js' -import { age } from '../color-formatters.js' -import { formatDate } from '../text-formatters.js' +import { renderDateBadge } from '../date.js' import VisualStudioMarketplaceBase from './visual-studio-marketplace-base.js' export default class VisualStudioMarketplaceReleaseDate extends VisualStudioMarketplaceBase { @@ -28,13 +27,6 @@ export default class VisualStudioMarketplaceReleaseDate extends VisualStudioMark label: 'release date', } - static render({ releaseDate }) { - return { - message: formatDate(releaseDate), - color: age(releaseDate), - } - } - transform({ json }) { const { extension } = this.transformExtension({ json }) const releaseDate = extension.releaseDate @@ -44,6 +36,6 @@ export default class VisualStudioMarketplaceReleaseDate extends VisualStudioMark async handle({ extensionId }) { const json = await this.fetch({ extensionId }) const { releaseDate } = this.transform({ json }) - return this.constructor.render({ releaseDate }) + return renderDateBadge(releaseDate) } } diff --git a/services/wordpress/wordpress-last-update.service.js b/services/wordpress/wordpress-last-update.service.js index 793a778831187..9543a3b8d0069 100644 --- a/services/wordpress/wordpress-last-update.service.js +++ b/services/wordpress/wordpress-last-update.service.js @@ -1,16 +1,12 @@ -import dayjs from 'dayjs' -import customParseFormat from 'dayjs/plugin/customParseFormat.js' -import { InvalidResponse, pathParams } from '../index.js' -import { formatDate } from '../text-formatters.js' -import { age as ageColor } from '../color-formatters.js' +import { pathParams } from '../index.js' +import { parseDate, renderDateBadge } from '../date.js' import { description, BaseWordpress } from './wordpress-base.js' -dayjs.extend(customParseFormat) const extensionData = { plugin: { capt: 'Plugin', exampleSlug: 'bbpress', - lastUpdateFormat: 'YYYY-MM-DD hh:mma [GMT]', + lastUpdateFormat: 'YYYY-MM-DD h:mma [GMT]', }, theme: { capt: 'Theme', @@ -50,35 +46,15 @@ function LastUpdateForType(extensionType) { static defaultBadgeData = { label: 'last updated' } - static render({ lastUpdated }) { - return { - label: 'last updated', - message: formatDate(lastUpdated), - color: ageColor(lastUpdated), - } - } - - transform(lastUpdate) { - const date = dayjs(lastUpdate, lastUpdateFormat) - - if (date.isValid()) { - return date.format('YYYY-MM-DD') - } else { - throw new InvalidResponse({ prettyMessage: 'invalid date' }) - } - } - async handle({ slug }) { const { last_updated: lastUpdated } = await this.fetch({ extensionType, slug, }) - const newDate = this.transform(lastUpdated) + const date = parseDate(lastUpdated, lastUpdateFormat) - return this.constructor.render({ - lastUpdated: newDate, - }) + return renderDateBadge(date) } } }