From 811e175cd040a00f5fee61b10fe4c0524c254601 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:39:58 +0200 Subject: [PATCH] fix: tell today's mail by the time zone setting in the sender line #1259 --- .../__tests__/mv-sender-date-zone.test.tsx | 106 ++++++++++++++++++ components/email/email-viewer.tsx | 8 +- lib/__tests__/format-relative-ago.test.ts | 6 + lib/utils.ts | 5 +- 4 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 components/email/__tests__/mv-sender-date-zone.test.tsx diff --git a/components/email/__tests__/mv-sender-date-zone.test.tsx b/components/email/__tests__/mv-sender-date-zone.test.tsx new file mode 100644 index 000000000..e1dfef7aa --- /dev/null +++ b/components/email/__tests__/mv-sender-date-zone.test.tsx @@ -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 { + 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( + , + ); + const card = container.querySelector('[data-mv-conversation-card][data-email-id="m1"]'); + expect(card?.textContent).toContain('21:00'); + expect(card?.textContent).toContain('2026'); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index dba3d4ac4..2427a5e86 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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' }); diff --git a/lib/__tests__/format-relative-ago.test.ts b/lib/__tests__/format-relative-ago.test.ts index befcfba80..73e446b1c 100644 --- a/lib/__tests__/format-relative-ago.test.ts +++ b/lib/__tests__/format-relative-ago.test.ts @@ -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'); + }); }); diff --git a/lib/utils.ts b/lib/utils.ts index 1af71cfcd..584aff124 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -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'); }