Strange format duration with date-fns for simple case

268 Views Asked by At

I have a strange format case with date-fns and formatting duration. If somebody have an idea what I'm doing wrong:

function formatDuration(duration) {
  return duration >= 60000 * 60
    ? format(new Date(duration), "hh 'h', mm 'min', ss 'sec'")
    : duration >= 60000
      ? format(new Date(duration), "mm 'min', ss 'sec'")
      : format(new Date(duration), "ss 'sec'");
}

What I expect:

formatDuration((59 * 60 + 59) * 1000); // '59 min, 59 sec'
formatDuration((60 * 60) * 1000); // '01 h 00 min, 00 sec'

What I get:

formatDuration((59 * 60 + 59) * 1000); // '59 min, 59 sec'
formatDuration((60 * 60) * 1000); // '02 h 00 min, 00 sec'

In the second case, there is an extra hour appearing from nowhere. I don't understand where the problem is. Any idea? Thanks!

4

There are 4 best solutions below

0
MathKimRobin On BEST ANSWER

Some of you were true that using Date was not a good idea. Manipulating number was more successfull. Here is my solution to my problem:

function formatDuration(duration) {
  if (!duration) {
    return '';
  }

  const durationSeconds = duration / 1000;

  const hours = Math.floor(durationSeconds / 3600);

  const restHours = durationSeconds % 3600;

  const minutes = Math.floor(restHours / 60);

  const seconds = restHours % 60;

  const hoursPart = hours > 0 ? `${hours} h, ` : '';

  let minutesPart;
  if (hours > 0) {
    if (minutes > 0) {
      minutesPart = `${minutes.toString().padStart(2, '0')} min, `;
    } else {
      minutesPart = '00 min, ';
    }
      } else {
    if (minutes > 0) {
      minutesPart = `${minutes} min, `;
    } else {
      minutesPart = '';
    }
  }

  let secondsPart;
  if (hours > 0 || minutes > 0) {
    if (seconds > 0) {
      secondsPart = `${seconds.toString().padStart(2, '0')} sec`;
    } else {
      secondsPart = '00 sec';
    }
  } else {
    if (seconds > 0) {
      secondsPart = `${seconds} sec`;
    } else {
      secondsPart = '';
    }
  }

  return `${hoursPart}${minutesPart}${secondsPart}`;
}

Maybe there is a more simple solution than this one but its working well and it's ok for all my cases.

Tested with this, all green:

describe('duration_format', () => {
  const cases = [
    [2, '2 sec'],
    [9, '9 sec'],
    [14, '14 sec'],
    [23, '23 sec'],
    [36, '36 sec'],
    [47, '47 sec'],
    [51, '51 sec'],
    [59, '59 sec'],
    [60, '1 min, 00 sec'],
    [61, '1 min, 01 sec'],
    [82, '1 min, 22 sec'],
    [154, '2 min, 34 sec'],
    [887, '14 min, 47 sec'],
    [30 * 60 + 22, '30 min, 22 sec'],
    [59 * 60 + 22, '59 min, 22 sec'],
    [59 * 60 + 59, '59 min, 59 sec'],
    [60 * 60, '1 h, 00 min, 00 sec'],
    [12 * 60 * 60 + 45 * 60 + 22, '12 h, 45 min, 22 sec'],
  ];

  test.each(cases)('%s to %s', (duration, humanReadable) => {
    expect(formatDuration(duration * 1000)).toBe(humanReadable);
  });
});
1
ghybs On

Because you create a new Date(duration), duration is interpreted as a timestamp, i.e. from 01-Jan-1970 00:00:00 UTC.

But date-fns takes that Date object as is, and interpretes it in the locale of the current JS engine, e.g. in UTC+1 if you are in metropolitan France in winter. So midnight UTC is already 1am in UTC+1. Hence your extra 1 hour.

If you handle only durations, you could just divide your number of milliseconds appropriately, instead of going through the headache of converting dates and timezone.

Another solution could be to make sure to create your date in the local timezone as well, e.g. by adding its offset:

const offset = (new Date()).getTimezoneOffset() * 60 * 1000;

const date = new Date(duration + offset);

// Then...
format(date, "hh 'h', mm 'min', ss 'sec'")

Live demo: https://plnkr.co/edit/OZF7PiCjZfmCEHpN?open=lib%2Fscript.js

Note that this might still bring strange effects, as it would assume a duration from 01-Jan-1970 (not a leap year, gap across DST switch, etc.).

I guess it would return a wrong result again as soon as you try running it in DST, because the offset would then take DST into account, but a short duration would fall before the DST had kicked in. Hence the created Date would have incorrect time.

0
derpirscher On

I'd suggest not to misuse the Date object (which is a timestamp) as duration, because whatever hh resolves to, depends on the timezone your process is currently running in (or you have a formatting library, that can be told to only use UTC). From your result, it seems you are currently in a timezone UTC+1. When you do new Date(3600000) this is actually1970-01-01T01:00:00Z (ie UTC). But this is also 1970-01-01T02:00:00 GMT+1 in your local timezone. Try executing new Date(3600000).toLocaleString() in node or your browser's developer console and you will see, the result contains 02 for the hours. And this 02 is what date-fns uses to format the date, because it formats timestamps according to your local timezone.

Furthermore, this approach will also lead to wrong results if your duration gets longer than 12/24 hours, because depending on whether you use a 12 (hh) or 24 (HH) hour clock, the hour component can never be greater than 12 or 24 respectively. You are using hh, thus the longest duration you can format is 12 hours.

The safest approach is use a library, that has an explicit type for durations and supports formatting them with custom format strings. If you don't have such a library, you can also calculate the hours, minutes ... yourself from the duration. This way you are safe from timezones and overflows.

let 
  hin = 75, min = 23, sin = 54, msin = 123, //input hours, minutes, seconds 
  duration = (hin * 3600 + min * 60 + sin) * 1000 + 123; //duration in milliseconds
  
  
let h = Math.floor(duration / 3600000);  //get the number of full hours
duration %= 3600000;  //remove the full hours from the duration
let m = Math.floor(duration / 60000);  //the the number of full minutes from the remaining duration
duration %= 60000;  //remove the full minutes from the duration
let s = Math.floor(duration / 1000); //get the number of full seconds
duration %= 1000;   //remove the full seconds from the duration
let ms = duration;  //the remaing duration are the milliseconds

console.log("input:", hin, min, sin, msin);
console.log("result:", h, m, s, msin);  //this should give the same values as the input

let 
  hs = `${h}`.padStart(2, "0");  //use the string.padStart function to add a leading 0 if needed
  ms = `${m}`.padStart(2, "0");  //use the string.padStart function to add a leading 0 if needed
  ss = `${s}`.padStart(2, "0");  //use the string.padStart function to add a leading 0 if needed 
  mss = `${ms}`.padStart(3, "0");  //use the string.padStart function to add a leading 0 if needed (ms has  three places)
  
let durationformat = h > 0
  ? `${hs} h ${ms} min ${ss} sec` 
  : `${ms} min ${ss} sec` ;
  
console.log("formatted:", durationformat)

Another way to get the (somewhat) correct hours, minutes and seconds is parsing them from the result of toISOString(), but again with the drawback, that you cannot represent durations longer than 24 hours this way.

let 
  hin = 14, min = 23, sin = 54, msin = 123, //input hours, minutes, seconds 
  duration = (hin * 3600 + min * 60 + sin) * 1000 + 123; //duration in milliseconds

let 
  isostring = new Date(duration).toISOString(),
  [_, h, m, s, ms] = /^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2}):(\d{2})\.(\d{3})/.exec(isostring) ?? [];
  
console.log("input:", hin, min, sin, msin);
console.log("iso:", isostring);
console.log("result:", h, m, s, ms);
  
  
//do the formatting as in the snippet above

Or if you want to stick with date-fns, you should make sure you are formatting the correct timestamp. Manipulating the duration with the current UTC offset like @ghybs suggests will give you wrong results when you enter DST, because then it will correct the duration by 2 hours, but the UTC offset on 1970-01-01 is only one hour. An easy way to fix this is to use

const offset = (new Date(0)).getTimezoneOffset() * 60 * 1000;
const date = new Date(duration + offset);

instead. Note the 0 I passed to the new Date(0) constructor. This will always give you the correct offset of your timezone on Jan 1st 1970, and thus correct the duration by the correct amount of time.

An alternative solution, is passing a string instead of a Date to the format function of date-fns. This string is then parsed to a Date. And the crucial point is, if the string doesn't contain any timezone information, date-fns will assume the timezone the process.

So how to construct a correct timestring? Easy, just use Date.toISOString() and cut away the trailing Z (which carries the timeszone information)

format(new Date(duration).toISOString().slice(0, -1), "hh 'h', mm 'min', ss 'sec'")

Why does this work? new Date(3600000).toISOString() will create the following string 1970-01-01T01:00:00.000Z, ie Jan 1st 1970, 1:00 AM in UTC timezone. If you throw away the Z (denoting the UTC timezone) you get 1970-01-01T01:00:00.000 which date-fns will interpret as Jan 1st 1970 1:00 AM in your current timezone, and thus printing the correct value for hh

But as both of these variants are kind of hacky, I'd still suggest to use something that explicitly handles durations and is able to format them accordingly or calculate hours, minutes and seconds yourself.

1
Smith On

The issue you're encountering with the unexpected extra hour in the second case is due to the way the format function from date-fns library works. When you pass a duration directly to the format function, it treats it as a timestamp (number of milliseconds since January 1, 1970), rather than a duration.

To correctly format durations, you can use the formatDuration function provided by the date-fns library itself. Here's an updated version of your code that uses formatDuration:

import { formatDuration } from 'date-fns';

function formatDurationCustom(duration) {
  const milliseconds = duration % 1000;
  const seconds = Math.floor((duration / 1000) % 60);
  const minutes = Math.floor((duration / (1000 * 60)) % 60);
  const hours = Math.floor((duration / (1000 * 60 * 60)) % 24);

  return formatDuration({
    hours,
    minutes,
    seconds,
    milliseconds,
  });
}

console.log(formatDurationCustom((59 * 60 + 59) * 1000)); // '59 min, 59 sec'
console.log(formatDurationCustom((60 * 60) * 1000)); // '1 hr, 0 min, 0 sec'

In this updated code, we're using the formatDuration function from the date-fns library to correctly format the durations. We calculate the separate units of the duration (hours, minutes, seconds, and milliseconds) and pass them as an object to formatDuration. The function will handle formatting the duration based on the provided units.

With this approach, you should get the expected output:

basic Copy

59 min, 59 sec

1 hr, 0 min, 0 sec

Make sure you have the date-fns library installed and imported correctly in your project for this code to work.