mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add time display component (#460)
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { isNumber } from '../predicate/predicate';
|
||||
|
||||
const UNIT_LABELS = [
|
||||
{ singular: 'hour', plural: 'hours' },
|
||||
{ singular: 'minute', plural: 'minutes' },
|
||||
{ singular: 'second', plural: 'seconds' },
|
||||
] as const;
|
||||
|
||||
function isValidTime(value: number): boolean {
|
||||
return isNumber(value) && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function toTimeUnitPhrase(value: number, unitIndex: number): string {
|
||||
const label = value === 1 ? UNIT_LABELS[unitIndex]?.singular : UNIT_LABELS[unitIndex]?.plural;
|
||||
return `${value} ${label}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds to digital display string.
|
||||
*
|
||||
* @param seconds - Time in seconds (can be negative)
|
||||
* @param guide - Guide time (typically duration) to determine display format
|
||||
* @returns Formatted string like "1:30" or "1:05:30"
|
||||
*
|
||||
* @example
|
||||
* formatTime(90) // "1:30"
|
||||
* formatTime(3661) // "1:01:01"
|
||||
* formatTime(35, 3600) // "0:00:35" (guided by 1-hour duration)
|
||||
* formatTime(35, 600) // "00:35" (guided by 10-minute duration)
|
||||
*/
|
||||
export function formatTime(seconds: number, guide?: number): string {
|
||||
if (!isValidTime(seconds)) {
|
||||
return '0:00';
|
||||
}
|
||||
|
||||
const negative = seconds < 0;
|
||||
const positiveSeconds = Math.abs(seconds);
|
||||
|
||||
const h = Math.floor(positiveSeconds / 3600);
|
||||
const m = Math.floor((positiveSeconds / 60) % 60);
|
||||
const s = Math.floor(positiveSeconds % 60);
|
||||
|
||||
const guideAbs = guide ? Math.abs(guide) : 0;
|
||||
const gh = Math.floor(guideAbs / 3600);
|
||||
const gm = Math.floor((guideAbs / 60) % 60);
|
||||
|
||||
const showHours = h > 0 || gh > 0;
|
||||
// Add leading zero to minutes if hours showing OR guide minutes >= 10
|
||||
const padMinutes = showHours || gm >= 10;
|
||||
|
||||
const hoursStr = showHours ? `${h}:` : '';
|
||||
const minutesStr = `${padMinutes && m < 10 ? '0' : ''}${m}:`;
|
||||
const secondsStr = s < 10 ? `0${s}` : `${s}`;
|
||||
|
||||
return `${negative ? '-' : ''}${hoursStr}${minutesStr}${secondsStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds to human-readable phrase for screen readers.
|
||||
*
|
||||
* @param seconds - Time in seconds (negative indicates remaining)
|
||||
* @returns Human-readable phrase like "1 minute, 30 seconds"
|
||||
*
|
||||
* @example
|
||||
* formatTimeAsPhrase(90) // "1 minute, 30 seconds"
|
||||
* formatTimeAsPhrase(3661) // "1 hour, 1 minute, 1 second"
|
||||
* formatTimeAsPhrase(-270) // "4 minutes, 30 seconds remaining"
|
||||
*/
|
||||
export function formatTimeAsPhrase(seconds: number): string {
|
||||
if (!isValidTime(seconds)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const negative = seconds < 0;
|
||||
const positiveSeconds = Math.abs(seconds);
|
||||
|
||||
const h = Math.floor(positiveSeconds / 3600);
|
||||
const m = Math.floor((positiveSeconds / 60) % 60);
|
||||
const s = Math.floor(positiveSeconds % 60);
|
||||
|
||||
const parts = [h, m, s].map((value, index) => (value > 0 ? toTimeUnitPhrase(value, index) : null)).filter(Boolean);
|
||||
|
||||
const phrase = parts.join(', ');
|
||||
const suffix = negative ? ' remaining' : '';
|
||||
|
||||
return `${phrase}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert seconds to ISO 8601 duration for datetime attribute.
|
||||
*
|
||||
* @param seconds - Time in seconds
|
||||
* @returns ISO 8601 duration string like "PT1M30S"
|
||||
*
|
||||
* @example
|
||||
* secondsToIsoDuration(90) // "PT1M30S"
|
||||
* secondsToIsoDuration(3661) // "PT1H1M1S"
|
||||
*/
|
||||
export function secondsToIsoDuration(seconds: number): string {
|
||||
if (!isValidTime(seconds)) {
|
||||
return 'PT0S';
|
||||
}
|
||||
|
||||
const positiveSeconds = Math.abs(seconds);
|
||||
|
||||
const h = Math.floor(positiveSeconds / 3600);
|
||||
const m = Math.floor((positiveSeconds / 60) % 60);
|
||||
const s = Math.floor(positiveSeconds % 60);
|
||||
|
||||
let duration = 'PT';
|
||||
if (h > 0) duration += `${h}H`;
|
||||
if (m > 0) duration += `${m}M`;
|
||||
if (s > 0 || duration === 'PT') duration += `${s}S`;
|
||||
|
||||
return duration;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './format';
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatTime, formatTimeAsPhrase, secondsToIsoDuration } from '../format';
|
||||
|
||||
describe('formatTime', () => {
|
||||
it('formats seconds only', () => {
|
||||
expect(formatTime(0)).toBe('0:00');
|
||||
expect(formatTime(5)).toBe('0:05');
|
||||
expect(formatTime(30)).toBe('0:30');
|
||||
expect(formatTime(59)).toBe('0:59');
|
||||
});
|
||||
|
||||
it('formats minutes and seconds', () => {
|
||||
expect(formatTime(60)).toBe('1:00');
|
||||
expect(formatTime(90)).toBe('1:30');
|
||||
expect(formatTime(125)).toBe('2:05');
|
||||
expect(formatTime(599)).toBe('9:59');
|
||||
expect(formatTime(600)).toBe('10:00');
|
||||
});
|
||||
|
||||
it('formats hours, minutes, and seconds', () => {
|
||||
expect(formatTime(3600)).toBe('1:00:00');
|
||||
expect(formatTime(3661)).toBe('1:01:01');
|
||||
expect(formatTime(7325)).toBe('2:02:05');
|
||||
expect(formatTime(36000)).toBe('10:00:00');
|
||||
});
|
||||
|
||||
it('pads minutes when hours are shown', () => {
|
||||
expect(formatTime(3605)).toBe('1:00:05');
|
||||
expect(formatTime(3660)).toBe('1:01:00');
|
||||
});
|
||||
|
||||
it('handles negative values', () => {
|
||||
expect(formatTime(-90)).toBe('-1:30');
|
||||
expect(formatTime(-3661)).toBe('-1:01:01');
|
||||
});
|
||||
|
||||
it('uses guide to determine hour display', () => {
|
||||
expect(formatTime(35, 3600)).toBe('0:00:35');
|
||||
expect(formatTime(90, 7200)).toBe('0:01:30');
|
||||
});
|
||||
|
||||
it('pads minutes when guide minutes >= 10', () => {
|
||||
// 10 minute guide (600s) should pad minutes for consistent width
|
||||
expect(formatTime(35, 600)).toBe('00:35');
|
||||
expect(formatTime(5, 600)).toBe('00:05');
|
||||
expect(formatTime(65, 600)).toBe('01:05');
|
||||
// 9 minute guide should not pad
|
||||
expect(formatTime(35, 540)).toBe('0:35');
|
||||
});
|
||||
|
||||
it('handles invalid values', () => {
|
||||
expect(formatTime(NaN)).toBe('0:00');
|
||||
expect(formatTime(Infinity)).toBe('0:00');
|
||||
expect(formatTime(-Infinity)).toBe('0:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTimeAsPhrase', () => {
|
||||
it('formats seconds only', () => {
|
||||
expect(formatTimeAsPhrase(1)).toBe('1 second');
|
||||
expect(formatTimeAsPhrase(30)).toBe('30 seconds');
|
||||
});
|
||||
|
||||
it('formats minutes and seconds', () => {
|
||||
expect(formatTimeAsPhrase(60)).toBe('1 minute');
|
||||
expect(formatTimeAsPhrase(90)).toBe('1 minute, 30 seconds');
|
||||
expect(formatTimeAsPhrase(125)).toBe('2 minutes, 5 seconds');
|
||||
});
|
||||
|
||||
it('formats hours, minutes, and seconds', () => {
|
||||
expect(formatTimeAsPhrase(3600)).toBe('1 hour');
|
||||
expect(formatTimeAsPhrase(3661)).toBe('1 hour, 1 minute, 1 second');
|
||||
expect(formatTimeAsPhrase(7325)).toBe('2 hours, 2 minutes, 5 seconds');
|
||||
});
|
||||
|
||||
it('handles singular vs plural', () => {
|
||||
expect(formatTimeAsPhrase(1)).toBe('1 second');
|
||||
expect(formatTimeAsPhrase(2)).toBe('2 seconds');
|
||||
expect(formatTimeAsPhrase(60)).toBe('1 minute');
|
||||
expect(formatTimeAsPhrase(120)).toBe('2 minutes');
|
||||
expect(formatTimeAsPhrase(3600)).toBe('1 hour');
|
||||
expect(formatTimeAsPhrase(7200)).toBe('2 hours');
|
||||
});
|
||||
|
||||
it('adds remaining suffix for negative values', () => {
|
||||
expect(formatTimeAsPhrase(-30)).toBe('30 seconds remaining');
|
||||
expect(formatTimeAsPhrase(-90)).toBe('1 minute, 30 seconds remaining');
|
||||
expect(formatTimeAsPhrase(-3661)).toBe('1 hour, 1 minute, 1 second remaining');
|
||||
});
|
||||
|
||||
it('handles invalid values', () => {
|
||||
expect(formatTimeAsPhrase(NaN)).toBe('');
|
||||
expect(formatTimeAsPhrase(Infinity)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondsToIsoDuration', () => {
|
||||
it('formats seconds only', () => {
|
||||
expect(secondsToIsoDuration(0)).toBe('PT0S');
|
||||
expect(secondsToIsoDuration(30)).toBe('PT30S');
|
||||
});
|
||||
|
||||
it('formats minutes and seconds', () => {
|
||||
expect(secondsToIsoDuration(60)).toBe('PT1M');
|
||||
expect(secondsToIsoDuration(90)).toBe('PT1M30S');
|
||||
});
|
||||
|
||||
it('formats hours, minutes, and seconds', () => {
|
||||
expect(secondsToIsoDuration(3600)).toBe('PT1H');
|
||||
expect(secondsToIsoDuration(3661)).toBe('PT1H1M1S');
|
||||
expect(secondsToIsoDuration(7325)).toBe('PT2H2M5S');
|
||||
});
|
||||
|
||||
it('handles negative values (uses absolute)', () => {
|
||||
expect(secondsToIsoDuration(-90)).toBe('PT1M30S');
|
||||
});
|
||||
|
||||
it('handles invalid values', () => {
|
||||
expect(secondsToIsoDuration(NaN)).toBe('PT0S');
|
||||
expect(secondsToIsoDuration(Infinity)).toBe('PT0S');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user