refactor(time): one timestamp formatter honouring the clock/date settings (#139)
CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Audit of every rendered time found five families of ad-hoc formatting: the shared Time component + copies of its today/yesterday branch (forwarded header, thread summary, read receipts, device tile, moderation alerts, edit history), locale-default toLocale*String calls that ignored the user's 12/24 h and date-format settings (scheduled tray, reminders, schedule preview, notification snooze, bookmarks, threads list, search cache line, room insights, media gallery), a hard-coded en-US date in the activity log, and three relative-age variants. utils/formatTimestamp.ts now holds the rules — today → time; yesterday / tomorrow → day word + time; last 6 days → weekday + time; older → date + time in dateFormatString — plus autoDate / time / date / dateTime styles, formatDayDivider (full weekday), formatShortAge (room list) and formatRelativeAge (list rows). useTimestampFormatter binds them to the settings. 11 unit tests with an injected 'now'. Visible changes are limited to consistency: 12 h times keep the existing zero-padded hh:mm A; the a11y label and Created-by line use the user's date format instead of a fixed long month. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -2,27 +2,29 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import dayjs from 'dayjs';
|
||||
import { messageAriaLabel } from './a11y';
|
||||
import { timeDayMonthYear, timeHourMinute } from './time';
|
||||
|
||||
test('messageAriaLabel composes sender, date and time (24h)', () => {
|
||||
const ts = dayjs('2026-07-01T14:30:00').valueOf();
|
||||
assert.equal(
|
||||
messageAriaLabel('Alice', ts, true),
|
||||
`Alice, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, true)}`,
|
||||
messageAriaLabel('Alice', ts, { hour24Clock: true, dateFormatString: 'D MMM YYYY' }),
|
||||
'Alice, 1 Jul 2026 14:30',
|
||||
);
|
||||
});
|
||||
|
||||
test('messageAriaLabel honours the 12-hour clock preference', () => {
|
||||
test('messageAriaLabel honours the 12-hour clock and date-format preferences', () => {
|
||||
const ts = dayjs('2026-07-01T14:30:00').valueOf();
|
||||
assert.equal(
|
||||
messageAriaLabel('Bob', ts, false),
|
||||
`Bob, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, false)}`,
|
||||
messageAriaLabel('Bob', ts, { hour24Clock: false, dateFormatString: 'MM/DD/YYYY' }),
|
||||
'Bob, 07/01/2026 02:30 PM',
|
||||
);
|
||||
});
|
||||
|
||||
test('messageAriaLabel keeps the sender name verbatim as plain text', () => {
|
||||
const ts = dayjs('2026-07-01T09:05:00').valueOf();
|
||||
const label = messageAriaLabel('@user:example.org', ts, true);
|
||||
const label = messageAriaLabel('@user:example.org', ts, {
|
||||
hour24Clock: true,
|
||||
dateFormatString: '',
|
||||
});
|
||||
assert.ok(label.startsWith('@user:example.org, '));
|
||||
assert.ok(!label.includes('<'));
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { timeDayMonthYear, timeHourMinute } from './time';
|
||||
import { TimestampPrefs, formatTimestamp } from './formatTimestamp';
|
||||
|
||||
/**
|
||||
* Builds a plain-text accessible label for a message row, used when the
|
||||
@@ -7,8 +7,8 @@ import { timeDayMonthYear, timeHourMinute } from './time';
|
||||
*
|
||||
* @param sender - Sender display name (already resolved to a human string).
|
||||
* @param ts - Message origin timestamp in milliseconds.
|
||||
* @param hour24Clock - Whether to format the time using a 24-hour clock.
|
||||
* @returns A label such as `Alice, 1 July 2026 14:30`.
|
||||
* @param prefs - The user's clock/date preferences.
|
||||
* @returns A label such as `Alice, 1 Jul 2026 14:30`.
|
||||
*/
|
||||
export const messageAriaLabel = (sender: string, ts: number, hour24Clock: boolean): string =>
|
||||
`${sender}, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`;
|
||||
export const messageAriaLabel = (sender: string, ts: number, prefs: TimestampPrefs): string =>
|
||||
`${sender}, ${formatTimestamp(ts, prefs, 'dateTime')}`;
|
||||
|
||||
@@ -55,16 +55,22 @@ test('formatFriendlyDateTime: uses Today/Tomorrow/date prefixes', () => {
|
||||
const tomorrow = new Date(2026, 0, 6, 9, 0).getTime();
|
||||
const nextWeek = new Date(2026, 0, 12, 9, 0).getTime();
|
||||
|
||||
assert.ok(formatFriendlyDateTime(laterToday, now).startsWith('Today at '));
|
||||
assert.ok(formatFriendlyDateTime(tomorrow, now).startsWith('Tomorrow at '));
|
||||
const other = formatFriendlyDateTime(nextWeek, now);
|
||||
assert.ok(!other.startsWith('Today'));
|
||||
assert.ok(!other.startsWith('Tomorrow'));
|
||||
assert.ok(other.includes(' at '));
|
||||
const prefs = { hour24Clock: true, dateFormatString: 'D MMM YYYY' };
|
||||
assert.equal(formatFriendlyDateTime(laterToday, prefs, now), 'Today at 15:30');
|
||||
assert.equal(formatFriendlyDateTime(tomorrow, prefs, now), 'Tomorrow at 09:00');
|
||||
assert.equal(formatFriendlyDateTime(nextWeek, prefs, now), '12 Jan 2026 at 09:00');
|
||||
assert.equal(
|
||||
formatFriendlyDateTime(nextWeek, { hour24Clock: false, dateFormatString: 'MM/DD/YYYY' }, now),
|
||||
'01/12/2026 at 09:00 AM',
|
||||
);
|
||||
});
|
||||
|
||||
test('formatFriendlyDateTime: Tomorrow rolls over month/year boundaries', () => {
|
||||
const nye = new Date(2026, 11, 31, 23, 0).getTime();
|
||||
const jan1 = new Date(2027, 0, 1, 9, 0).getTime();
|
||||
assert.ok(formatFriendlyDateTime(jan1, nye).startsWith('Tomorrow at '));
|
||||
assert.ok(
|
||||
formatFriendlyDateTime(jan1, { hour24Clock: true, dateFormatString: '' }, nye).startsWith(
|
||||
'Tomorrow at ',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { color as foldsColor, config as foldsConfig } from 'folds';
|
||||
import { TimestampPrefs, dayWord, formatDate, formatTime } from './formatTimestamp';
|
||||
|
||||
const pad = (n: number): string => String(n).padStart(2, '0');
|
||||
|
||||
@@ -23,21 +24,17 @@ export function parseLocalDateTime(dateValue: string, timeValue: string): Date |
|
||||
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||
}
|
||||
|
||||
// Human-friendly absolute time: "Today at 3:00 PM", "Tomorrow at 9:00 AM", or
|
||||
// "1/5/2026 at 3:00 PM". `now` is injectable so the relative-day logic is testable.
|
||||
export function formatFriendlyDateTime(ts: number, now: number = Date.now()): string {
|
||||
const date = new Date(ts);
|
||||
const nowDate = new Date(now);
|
||||
const sameDay = (a: Date, b: Date): boolean =>
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate();
|
||||
const tomorrow = new Date(nowDate);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
if (sameDay(date, nowDate)) return `Today at ${timeStr}`;
|
||||
if (sameDay(date, tomorrow)) return `Tomorrow at ${timeStr}`;
|
||||
return `${date.toLocaleDateString()} at ${timeStr}`;
|
||||
// Human-friendly absolute time for scheduled things: "Today at 03:00 PM",
|
||||
// "Tomorrow at 09:00 AM", "Mon at 09:00", or "12 Jan 2026 at 09:00" — the
|
||||
// shared day-word rules and the user's clock/date preferences (#139). `now` is
|
||||
// injectable so the relative-day logic is testable.
|
||||
export function formatFriendlyDateTime(
|
||||
ts: number,
|
||||
prefs: TimestampPrefs,
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const day = dayWord(ts, now) ?? formatDate(ts, prefs);
|
||||
return `${day} at ${formatTime(ts, prefs)}`;
|
||||
}
|
||||
|
||||
// Shared style for date/time <input>s — matches the app's surface tokens and
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
formatDayDivider,
|
||||
formatRelativeAge,
|
||||
formatShortAge,
|
||||
formatTimestamp,
|
||||
} from './formatTimestamp';
|
||||
|
||||
// Fri 18 Sep 2026 14:05 local.
|
||||
const now = new Date(2026, 8, 18, 14, 5).getTime();
|
||||
const at = (y: number, m: number, d: number, h = 21, min = 14) =>
|
||||
new Date(y, m - 1, d, h, min).getTime();
|
||||
const p12 = { hour24Clock: false, dateFormatString: 'D MMM YYYY' };
|
||||
const p24 = { hour24Clock: true, dateFormatString: 'YYYY-MM-DD' };
|
||||
|
||||
describe('formatTimestamp', () => {
|
||||
it('today → time only', () => {
|
||||
assert.equal(formatTimestamp(at(2026, 9, 18, 9, 3), p12, 'auto', now), '09:03 AM');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 18, 9, 3), p24, 'auto', now), '09:03');
|
||||
});
|
||||
|
||||
it('yesterday / tomorrow → day word + time', () => {
|
||||
assert.equal(formatTimestamp(at(2026, 9, 17), p24, 'auto', now), 'Yesterday 21:14');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 19), p24, 'auto', now), 'Tomorrow 21:14');
|
||||
// Just before midnight yesterday is still yesterday, not "hours ago".
|
||||
assert.equal(formatTimestamp(at(2026, 9, 17, 23, 59), p24, 'auto', now), 'Yesterday 23:59');
|
||||
});
|
||||
|
||||
it('within the last week → weekday + time', () => {
|
||||
assert.equal(formatTimestamp(at(2026, 9, 14), p24, 'auto', now), 'Mon 21:14');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 12), p24, 'auto', now), 'Sat 21:14');
|
||||
});
|
||||
|
||||
it('a week or more → date + time in the user format', () => {
|
||||
assert.equal(formatTimestamp(at(2026, 9, 11), p24, 'auto', now), '2026-09-11 21:14');
|
||||
assert.equal(formatTimestamp(at(2025, 1, 2), p12, 'auto', now), '2 Jan 2025 09:14 PM');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 25), p12, 'auto', now), '25 Sep 2026 09:14 PM');
|
||||
});
|
||||
|
||||
it('autoDate never shows the time', () => {
|
||||
assert.equal(formatTimestamp(at(2026, 9, 18), p12, 'autoDate', now), 'Today');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 17), p12, 'autoDate', now), 'Yesterday');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 15), p12, 'autoDate', now), 'Tue');
|
||||
assert.equal(formatTimestamp(at(2026, 1, 15), p12, 'autoDate', now), '15 Jan 2026');
|
||||
});
|
||||
|
||||
it('fixed styles ignore the relative day', () => {
|
||||
assert.equal(formatTimestamp(at(2026, 9, 18), p12, 'time', now), '09:14 PM');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 18), p12, 'date', now), '18 Sep 2026');
|
||||
assert.equal(formatTimestamp(at(2026, 9, 18), p24, 'dateTime', now), '2026-09-18 21:14');
|
||||
});
|
||||
|
||||
it('falls back to a default date format when the preference is empty', () => {
|
||||
assert.equal(
|
||||
formatTimestamp(at(2026, 1, 15), { hour24Clock: true, dateFormatString: '' }, 'date', now),
|
||||
'15 Jan 2026',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatShortAge', () => {
|
||||
it('minutes and hours today', () => {
|
||||
assert.equal(formatShortAge(now - 20_000, p12, now), 'now');
|
||||
assert.equal(formatShortAge(now - 5 * 60_000, p12, now), '5m');
|
||||
assert.equal(formatShortAge(now - 3 * 3_600_000, p12, now), '3h');
|
||||
});
|
||||
|
||||
it('day words, then a short date (year dropped when current)', () => {
|
||||
assert.equal(formatShortAge(at(2026, 9, 17, 23, 0), p12, now), 'Yesterday');
|
||||
assert.equal(formatShortAge(at(2026, 9, 14), p12, now), 'Mon');
|
||||
assert.equal(formatShortAge(at(2026, 3, 2), p12, now), '2 Mar');
|
||||
assert.equal(formatShortAge(at(2026, 3, 2), p24, now), '03-02');
|
||||
assert.equal(formatShortAge(at(2025, 3, 2), p12, now), '2 Mar 2025');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRelativeAge', () => {
|
||||
it('steps through minutes, hours, days, then the date', () => {
|
||||
assert.equal(formatRelativeAge(now - 10_000, p12, now), 'just now');
|
||||
assert.equal(formatRelativeAge(now - 7 * 60_000, p12, now), '7m ago');
|
||||
assert.equal(formatRelativeAge(now - 5 * 3_600_000, p12, now), '5h ago');
|
||||
assert.equal(formatRelativeAge(at(2026, 9, 17, 8), p12, now), 'yesterday');
|
||||
assert.equal(formatRelativeAge(at(2026, 9, 15, 8), p12, now), '3d ago');
|
||||
assert.equal(formatRelativeAge(at(2026, 9, 1, 8), p24, now), '2026-09-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDayDivider', () => {
|
||||
it('uses the full weekday inside the last week', () => {
|
||||
assert.equal(formatDayDivider(at(2026, 9, 18), p12, now), 'Today');
|
||||
assert.equal(formatDayDivider(at(2026, 9, 17), p12, now), 'Yesterday');
|
||||
assert.equal(formatDayDivider(at(2026, 9, 14), p12, now), 'Monday');
|
||||
assert.equal(formatDayDivider(at(2026, 9, 11), p12, now), '11 Sep 2026');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* [Gitea #139] One place that turns a timestamp into words, honouring the
|
||||
* user's 12/24 h clock and date-format preferences everywhere.
|
||||
*
|
||||
* Rules for the `auto` styles, relative to `now`:
|
||||
* today → "21:14"
|
||||
* yesterday → "Yesterday 21:14"
|
||||
* tomorrow → "Tomorrow 21:14" (scheduled sends, reminders)
|
||||
* last 6 days → "Mon 21:14"
|
||||
* older / further → "5 Sep 2026 21:14" (date per `dateFormatString`)
|
||||
*/
|
||||
export type TimestampPrefs = {
|
||||
hour24Clock: boolean;
|
||||
dateFormatString: string;
|
||||
};
|
||||
|
||||
export type TimestampStyle =
|
||||
/** Time for today, day word + time for nearby days, date + time otherwise. */
|
||||
| 'auto'
|
||||
/** Like `auto` but never the time: "Today", "Yesterday", "Mon", "5 Sep 2026". */
|
||||
| 'autoDate'
|
||||
/** Always the clock time. */
|
||||
| 'time'
|
||||
/** Always the date per `dateFormatString`. */
|
||||
| 'date'
|
||||
/** Always date + time. */
|
||||
| 'dateTime';
|
||||
|
||||
const DEFAULT_DATE_FORMAT = 'D MMM YYYY';
|
||||
|
||||
const dateFormat = (prefs: TimestampPrefs) => prefs.dateFormatString || DEFAULT_DATE_FORMAT;
|
||||
|
||||
export const formatTime = (ts: number, prefs: Pick<TimestampPrefs, 'hour24Clock'>): string =>
|
||||
dayjs(ts).format(prefs.hour24Clock ? 'HH:mm' : 'hh:mm A');
|
||||
|
||||
export const formatDate = (ts: number, prefs: TimestampPrefs): string =>
|
||||
dayjs(ts).format(dateFormat(prefs));
|
||||
|
||||
/** Whole days between the start of `ts`'s day and the start of `now`'s day (negative = future). */
|
||||
export const dayDistance = (ts: number, now: number): number =>
|
||||
dayjs(now).startOf('day').diff(dayjs(ts).startOf('day'), 'day');
|
||||
|
||||
/** "Today" / "Yesterday" / "Tomorrow" / "Mon" for nearby days, else undefined. */
|
||||
export const dayWord = (ts: number, now: number): string | undefined => {
|
||||
const d = dayDistance(ts, now);
|
||||
if (d === 0) return 'Today';
|
||||
if (d === 1) return 'Yesterday';
|
||||
if (d === -1) return 'Tomorrow';
|
||||
if (d > 1 && d < 7) return dayjs(ts).format('ddd');
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export function formatTimestamp(
|
||||
ts: number,
|
||||
prefs: TimestampPrefs,
|
||||
style: TimestampStyle = 'auto',
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const time = formatTime(ts, prefs);
|
||||
if (style === 'time') return time;
|
||||
const date = formatDate(ts, prefs);
|
||||
if (style === 'date') return date;
|
||||
if (style === 'dateTime') return `${date} ${time}`;
|
||||
|
||||
const word = dayWord(ts, now);
|
||||
if (style === 'autoDate') return word ?? date;
|
||||
if (word === 'Today') return time;
|
||||
return `${word ?? date} ${time}`;
|
||||
}
|
||||
|
||||
/** Short relative age for dense lists: "now", "5m", "3h", "Yesterday", "Mon", "5 Sep". */
|
||||
export function formatShortAge(
|
||||
ts: number,
|
||||
prefs: TimestampPrefs,
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const diffMin = Math.floor((now - ts) / 60_000);
|
||||
if (diffMin < 60) return diffMin < 1 ? 'now' : `${diffMin}m`;
|
||||
const diffHours = Math.floor(diffMin / 60);
|
||||
if (diffHours < 24 && dayDistance(ts, now) === 0) return `${diffHours}h`;
|
||||
const word = dayWord(ts, now);
|
||||
if (word && word !== 'Today') return word;
|
||||
// Same year: drop the year from the user's format for brevity.
|
||||
const sameYear = dayjs(ts).year() === dayjs(now).year();
|
||||
const fmt = dateFormat(prefs);
|
||||
return dayjs(ts).format(sameYear ? fmt.replace(/[-/.\s]*Y{2,4}[-/.\s]*/, ' ').trim() : fmt);
|
||||
}
|
||||
|
||||
/** Conversational age for list rows: "just now", "5m ago", "3h ago", "yesterday", "3d ago", then the date. */
|
||||
export function formatRelativeAge(
|
||||
ts: number,
|
||||
prefs: TimestampPrefs,
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const diffMin = Math.floor((now - ts) / 60_000);
|
||||
if (diffMin < 1) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHours = Math.floor(diffMin / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const days = dayDistance(ts, now);
|
||||
if (days === 1) return 'yesterday';
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return formatDate(ts, prefs);
|
||||
}
|
||||
|
||||
/** Timeline day divider: "Today", "Yesterday", "Monday" (last 6 days), else the date. */
|
||||
export function formatDayDivider(
|
||||
ts: number,
|
||||
prefs: TimestampPrefs,
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const d = dayDistance(ts, now);
|
||||
if (d > 1 && d < 7) return dayjs(ts).format('dddd');
|
||||
return formatTimestamp(ts, prefs, 'autoDate', now);
|
||||
}
|
||||
Reference in New Issue
Block a user