This is a little funny:
base = DateTime.local(2018, 12, 31);
base.plus({ days: 1 }).toRelativeCalendar() //=> "next year"
This is technically correct, but I suspect most people would want that to say "tomorrow". Same issue with the end of months.
The question is: when should it say next year?
Same problem for months. And I haven't even introduced weeks, which is surely similar.
Optional parameter? Let's say: we define a default value 'next year' but the developer can send a parameter which indicates the value to be return
I'd say the closest boundary is the correct one, but overriding it with a parameter sounds interesting. I grant it's likely a major version bump, but I'd say if it's within 7 days and hits such a boundary, it should say "tomorrow, if it's within 8 days and hits a boundary, "next week", if it's within 32 days and hits a boundary, "next month". It sounds like we need to decide what "correct" is before we PR, right?
Yes, we need a spec. I like the idea that the threshold matches the size of the unit. I don't like the idea of overrides because I think it would be a confusing API. We can always add it later but we can't take it back once we do. So I'd like to make this change without expanding the API surface area.
I don't think this really requires a major version bump, either.
Here's a suggestion that I'm using in one of my projects where I face this problem frequently. I run some TDD in it that's why you can see some pattern generalization.
Feel free to use it as much as MIT and WTFPL allows you :P
TL;DR:
If it's one or more years apart, say "next year" or "in x years"
If it's one or more months apart, say "next month" or "in x months"
if it's one of more days apart, say "tomorrow" or "in x days"
Anything that is less than 1 day apart is counted as "in x hours", "in x minutes"... and so forth
If the next day ends up in a different month or year, then it's counted as "tomorrow"
const toRelativeYears = years => {
return years === 1
? 'next year'
: `in ${years} years`;
};
const toRelativeMonths = months => {
return months === 1
? 'next month'
: `in ${months} months`;
};
const toRelativeDays = days => {
return days === 1
? 'tomorrow'
: `in ${days} days`;
};
const toRelative = (value, unit) => {
return value === 1
? `in 1 ${unit.slice(0, unit.length - 1)}`
: `in ${value} ${unit}`;
};
module.exports = (source, comparison) => {
const units = ['days', 'hours', 'minutes', 'seconds'];
const diffObject = comparison.diff(source, units).toObject();
if (diffObject.days < 0 || diffObject.hours < 0 || diffObject.minutes < 0 || diffObject.seconds < 0) {
return 'in the past';
}
if (source.month !== comparison.month && source.plus({ days: 1 }).month === comparison.month) {
return 'tomorrow';
}
for (let i = 0; i < units.length; i++) {
const withinUnit = diffObject[units[i]] < 1 && diffObject[units[i]] >= 0;
if (withinUnit) {
const nextUnitIsGreaterThanOne = diffObject[units[i + 1]] >= 1;
if (nextUnitIsGreaterThanOne) {
return toRelative(diffObject[units[i + 1]], units[i + 1]);
}
}
}
const yearsApart = comparison.year - source.year;
const monthsApart = comparison.month - source.month;
const daysApart = comparison.day - source.day;
if (yearsApart >= 1) {
return toRelativeYears(yearsApart);
}
if (monthsApart >= 1) {
return toRelativeMonths(monthsApart);
}
if (daysApart >= 1) {
return toRelativeDays(daysApart)
}
return 'now';
};
Tests
const { expect } = require('chai');
const { DateTime } = require('luxon');
const toRelativeString = require('./to-relative-string');
const ClockAt = (timestamp) => DateTime.fromISO(timestamp, { zone: 'utc' })
describe('Special conditions', () => {
it('prints "now" if both compared dates are the same', () => {
expect(
toRelativeString(
ClockAt('2020-08-31T10:00:00.000Z'),
ClockAt('2020-08-31T00:00.000Z')
)
).to.eql('now');
});
describe('One day away', () => {
it('does not show as "next month" when "next month" is one day away', () => {
expect(
toRelativeString(
ClockAt('2020-08-31T10:00:00.000Z'),
ClockAt('2020-09-01T13:00:00.000Z')
)
).to.eql('tomorrow');
});
it('does not show as "next year" when "next year" is one day away', () => {
expect(
toRelativeString(
ClockAt('2020-12-31T10:00:00.000Z'),
ClockAt('2021-01-01T13:00:00.000Z')
)
).to.eql('tomorrow');
});
});
});
describe('In x years', () => {
it('calculates next year when dates are less than 12 months apart', () => {
expect(
toRelativeString(
ClockAt('2020-03-10T10:00:00Z'),
ClockAt('2021-02-10T10:00:00Z')
)
).to.eql('next year');
});
it('calculates "in 2 years" when dates are less than 24 months days apart', () => {
expect(
toRelativeString(
ClockAt('2020-03-10T10:00:00Z'),
ClockAt('2022-02-10T10:00:00Z')
)
).to.eql('in 2 years');
});
});
describe('In x months', () => {
it('calculates next month when dates are less than 30 days apart', () => {
expect(
toRelativeString(
ClockAt('2020-01-10T10:00:00Z'),
ClockAt('2020-02-05T10:00:00Z')
)
).to.eql('next month');
});
it('calculates "in 2 months" when dates are less than 60 days apart', () => {
expect(
toRelativeString(
ClockAt('2020-01-10T10:00:00Z'),
ClockAt('2020-03-05T10:00:00Z')
)
).to.eql('in 2 months');
});
});
describe('In x Days', () => {
it('calculates next day when dates are exactly 24 hours apart', () => {
expect(
toRelativeString(
ClockAt('2020-01-01T10:00:00Z'),
ClockAt('2020-01-02T10:00:00Z')
)
).to.eql('tomorrow');
});
it('calculates the next day when dates are 25 hours apart on the next day', () => {
expect(
toRelativeString(
ClockAt('2020-01-01T10:00:00Z'),
ClockAt('2020-01-02T11:00:00Z')
)
).to.eql('tomorrow');
});
it('calculates "in 2 days" on the SAME MONTH but DIFFERENT DAY and less than 48h', () => {
expect(
toRelativeString(
ClockAt('2020-01-01T10:00:00Z'),
ClockAt('2020-01-03T09:00:00Z')
)
).to.eql('in 2 days');
});
});
describe('In x hours', () => {
it('calculates the relative date when dates are in 1 hour', () => {
expect(
toRelativeString(
ClockAt('2020-01-01T10:00:00Z'),
ClockAt('2020-01-01T11:00:00Z')
)
).to.eql('in 1 hour');
});
it('calculates the relative date when dates are in 2 hours', () => {
expect(
toRelativeString(
ClockAt('2020-01-01T10:00:00Z'),
ClockAt('2020-01-01T12:00:00Z')
)
).to.eql('in 2 hours');
});
});
describe('In x minutes', () => {
it('calculates the relative date when dates are in 1 minute', () => {
expect(
toRelativeString(
ClockAt('2020-01-01T10:00:00Z'),
ClockAt('2020-01-01T10:01:00Z')
)
).to.eql('in 1 minute');
});
it('calculates the relative date when dates are in 2 minutes', () => {
expect(
toRelativeString(
ClockAt('2020-01-01T10:00:00Z'),
ClockAt('2020-01-01T10:02:00Z')
)
).to.eql('in 2 minutes');
});
});
describe('In x seconds', () => {
it('calculates the relative date when dates are in 1 second', () => {
expect(
toRelativeString(
ClockAt('2020-01-01T10:00:00Z'),
ClockAt('2020-01-01T10:00:01Z')
)
).to.eql('in 1 second');
});
it('calculates the relative date when dates are in 2 seconds', () => {
expect(
toRelativeString(
ClockAt('2020-01-01T10:00:00Z'),
ClockAt('2020-01-01T10:00:02Z')
)
).to.eql('in 2 seconds');
});
});
describe('Relative time in the past', () => {
it('One day', () => {
expect(
toRelativeString(
ClockAt('2020-01-02T10:00:00Z'),
ClockAt('2020-01-01T10:00:00Z')
)
).to.eql('in the past');
});
it('One hour', () => {
expect(
toRelativeString(
ClockAt('2020-01-01T10:00:00Z'),
ClockAt('2020-01-01T09:00:00Z')
)
).to.eql('in the past');
});
it('One minute', () => {
expect(
toRelativeString(
ClockAt('2020-01-01T10:02:00Z'),
ClockAt('2020-01-01T10:01:00Z')
)
).to.eql('in the past');
});
it('One second', () => {
expect(
toRelativeString(
ClockAt('2020-01-01T10:01:59Z'),
ClockAt('2020-01-01T10:01:58Z')
)
).to.eql('in the past');
});
});
Bump! This also is a problem if tomorrow is another month. No one would say next month, they would say 'tomorrow'
Most helpful comment
Yes, we need a spec. I like the idea that the threshold matches the size of the unit. I don't like the idea of overrides because I think it would be a confusing API. We can always add it later but we can't take it back once we do. So I'd like to make this change without expanding the API surface area.
I don't think this really requires a major version bump, either.