fix: tell today's mail by the time zone setting in the sender line #1259

This commit is contained in:
Linus Rath
2026-10-18 14:39:58 +02:00
parent 610df64463
commit 811e175cd0
4 changed files with 122 additions and 3 deletions
@@ -0,0 +1,106 @@
import { render } from '@testing-library/react';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { useSettingsStore } from '@/stores/settings-store';
// #1259: the Mountain View sender line shows only the time for today's mail
// and the date as well before that. "Today" is the day in the time zone
// setting, the zone the time and the "(… ago)" after it are shown in, not the
// browser's.
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
}));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({
PluginSlot: () => null,
}));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({
usePluginSlotOffers: () => [],
}));
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
const { EmailViewer } = await import('../email-viewer');
const mailboxes: Mailbox[] = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
function mail(n: number, receivedAt: string, overrides: Partial<Email> = {}): Email {
return {
id: `m${n}`,
blobId: `b${n}`,
threadId: 't1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
from: [{ name: `Sender ${n}`, email: `s${n}@example.com` }],
to: [{ name: 'Me', email: 'me@example.com' }],
subject: 'Subject',
receivedAt,
preview: `Preview ${n}`,
hasAttachment: false,
size: 100,
...overrides,
} as unknown as Email;
}
let originalTZ: string | undefined;
describe('Mountain View sender date (#1259)', () => {
beforeAll(() => {
// The browser's zone, a day ahead of Denver in the evening.
originalTZ = process.env.TZ;
process.env.TZ = 'UTC';
});
afterAll(() => {
process.env.TZ = originalTZ;
});
afterEach(() => {
vi.useRealTimers();
useSettingsStore.setState({ timeZone: 'auto' });
});
it("shows the date for yesterday's mail in the time zone setting", () => {
vi.useFakeTimers({ toFake: ['Date'] });
// 16 Oct, 10:00 in Denver.
vi.setSystemTime(new Date('2026-10-16T16:00:00Z'));
useSettingsStore.setState({
interfaceLayout: 'mountain-view', markAsReadDelay: 0, timeZone: 'America/Denver', timeFormat: '24h',
});
// 15 Oct, 21:00 in Denver, which is already 16 Oct in UTC.
const yesterday = mail(1, '2026-10-16T03:00:00Z');
const open = {
...mail(2, '2026-10-16T15:00:00Z'),
textBody: [{ partId: '1', type: 'text/plain' }],
htmlBody: [],
attachments: [],
bodyValues: { '1': { value: 'Open body', isTruncated: false } },
} as unknown as Email;
const { container } = render(
<EmailViewer
email={open}
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole="inbox"
mvConversation={[yesterday, open]}
/>,
);
const card = container.querySelector('[data-mv-conversation-card][data-email-id="m1"]');
expect(card?.textContent).toContain('21:00');
expect(card?.textContent).toContain('2026');
});
});
+7 -1
View File
@@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { SubMenuPanel } from "@/components/ui/context-menu";
import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils";
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
import { itemsToHide } from "@/lib/toolbar-overflow";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
@@ -689,7 +690,12 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl
*/
function mvSenderDate(date: Date | string, timeFormat: '12h' | '24h'): string {
const d = typeof date === 'string' ? new Date(date) : date;
const today = new Date().toDateString() === d.toDateString();
// "Today" in the time zone setting, the zone the time and the relative
// part are shown in, not the browser's (#1259).
const zone = getEffectiveTimeZone();
const day = Number.isNaN(d.getTime()) ? null : getWallClock(d, zone);
const now = getWallClock(new Date(), zone);
const today = !!day && day.year === now.year && day.month === now.month && day.day === now.day;
const when = today
? formatDateTime(d, timeFormat)
: formatDateTime(d, timeFormat, { year: 'numeric', month: 'short', day: 'numeric' });
@@ -32,4 +32,10 @@ describe('formatRelativeAgo', () => {
// Under a day still reads in hours, even across midnight.
expect(formatRelativeAgo(new Date('2026-10-15T23:00:00Z'), denverNow)).toBe('17 hours ago');
});
it('does not call the same day yesterday on the 25-hour day the clocks go back', () => {
useSettingsStore.setState({ timeZone: 'America/Denver' });
// 1 Nov 2026 in Denver: 00:10 MDT, and 24.5 hours later 23:40 MST.
expect(formatRelativeAgo(new Date('2026-11-01T06:10:00Z'), new Date('2026-11-02T06:40:00Z'))).toBe('24 hours ago');
});
});
+3 -2
View File
@@ -274,13 +274,14 @@ export function formatRelativeAgo(date: Date | string, now: Date = new Date()):
const w = getWallClock(x, timeZone);
return Date.UTC(w.year, w.month - 1, w.day) / 86400000;
};
const days = Math.max(1, dayNumber(now) - dayNumber(d));
const days = dayNumber(now) - dayNumber(d);
if (days >= 30) return '';
const locale = getEffectiveLocale();
const rtf = new Intl.RelativeTimeFormat(locale === 'en' ? 'en-US' : locale, { numeric: 'auto' });
if (minutes < 1) return rtf.format(0, 'second');
if (minutes < 60) return rtf.format(-minutes, 'minute');
if (hours < 24) return rtf.format(-hours, 'hour');
// The day the clocks go back has 25 hours: 24 of them can still be today.
if (hours < 24 || days < 1) return rtf.format(-hours, 'hour');
return rtf.format(-days, 'day');
}