From a7a3148949fbf990daeaf0062e5c17636cc54ff1 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 15 May 2026 01:06:46 +0200 Subject: [PATCH] fix(format): round human duration totals to the nearest minute (#710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(format): round human duration totals to the nearest minute The Phase L dedup refactor folded the old `formatAlbumDuration` helper into `formatHumanHoursMinutes`, but the shared version truncated seconds to whole minutes instead of rounding. Aggregate duration labels (album, playlist, total playtime) could read up to ~59 s short and flip the hour boundary the wrong way — a 59:30 total showed "59 m" instead of "1 h 0 m". Restore the round-to-nearest-minute behaviour (and the negative-input clamp) the helper had before the consolidation. Adds a test pinning the rounding and the hour-boundary roll-up so it can't regress again. * docs(changelog): add human-duration rounding fix under Fixed (#710) --- CHANGELOG.md | 6 ++++ src/utils/format/formatHumanDuration.test.ts | 35 ++++++++++++++++++++ src/utils/format/formatHumanDuration.ts | 15 ++++++--- 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 src/utils/format/formatHumanDuration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c23186..bce2bc64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -284,6 +284,12 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa ## Fixed +### Statistics / playlists — duration totals rounded to the nearest minute again + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#710](https://github.com/Psychotoxical/psysonic/pull/710)** + +* Aggregate duration labels (album and playlist totals, total playtime in **Statistics**) could read up to ~59 s short and round the wrong way at the hour boundary — a 59:30 total showed **"59 m"** instead of **"1 h 0 m"**. A `format` helper consolidation had switched the shared formatter from rounding to truncating; the round-to-nearest-minute behaviour is restored. + ### Artists — infinite scroll after first page **By [@cucadmuh](https://github.com/cucadmuh), PR [#709](https://github.com/Psychotoxical/psysonic/pull/709)** diff --git a/src/utils/format/formatHumanDuration.test.ts b/src/utils/format/formatHumanDuration.test.ts new file mode 100644 index 00000000..95861c8f --- /dev/null +++ b/src/utils/format/formatHumanDuration.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Echo the i18n key + params so the rounding/branching can be asserted +// without depending on a locale's exact "N hours M minutes" template. +vi.mock('../../i18n', () => ({ + default: { + t: (key: string, params: Record) => `${key}|${JSON.stringify(params)}`, + }, +})); + +import { formatHumanHoursMinutes } from './formatHumanDuration'; + +describe('formatHumanHoursMinutes', () => { + it('rounds to the nearest minute instead of truncating', () => { + // 3 min 30 s → rounds up to 4 min + expect(formatHumanHoursMinutes(210)).toBe('common.durationMinutesOnly|{"minutes":4}'); + // 3 min 29 s → rounds down to 3 min + expect(formatHumanHoursMinutes(209)).toBe('common.durationMinutesOnly|{"minutes":3}'); + }); + + it('rolls a near-hour total up into the hours branch', () => { + // 59 min 30 s → rounds to 60 min → "1 h 0 m", not "59 m" + expect(formatHumanHoursMinutes(3570)).toBe('common.durationHoursMinutes|{"hours":"1","minutes":0}'); + }); + + it('formats hours plus minutes', () => { + expect(formatHumanHoursMinutes(3 * 3600 + 25 * 60)).toBe( + 'common.durationHoursMinutes|{"hours":"3","minutes":25}', + ); + }); + + it('clamps negative input to zero', () => { + expect(formatHumanHoursMinutes(-5)).toBe('common.durationMinutesOnly|{"minutes":0}'); + }); +}); diff --git a/src/utils/format/formatHumanDuration.ts b/src/utils/format/formatHumanDuration.ts index 59f8f0d9..c24fa4c3 100644 --- a/src/utils/format/formatHumanDuration.ts +++ b/src/utils/format/formatHumanDuration.ts @@ -1,11 +1,18 @@ import i18n from '../../i18n'; -/** Totals / statistics: localized "N hours M minutes" (not track mm:ss). */ +/** + * Totals / statistics: localized "N hours M minutes" (not track mm:ss). + * + * Rounds to the nearest minute before splitting into hours/minutes — a 3:21:40 + * total reads as "3 h 22 m", and a 59:30 total rolls up to "1 h 0 m" rather + * than truncating to "59 m". Negative input is clamped to zero. + */ export function formatHumanHoursMinutes(seconds: number): string { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); + const totalMin = Math.max(0, Math.round(seconds / 60)); + const h = Math.floor(totalMin / 60); + const m = totalMin % 60; if (h > 0) { return i18n.t('common.durationHoursMinutes', { hours: h.toLocaleString(), minutes: m }); } - return i18n.t('common.durationMinutesOnly', { minutes: m }); + return i18n.t('common.durationMinutesOnly', { minutes: totalMin }); }