From 8fe5798bf581ef3aeddfb6a4bdfc1acf192ac35d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:46:43 +0100 Subject: [PATCH] fix: show scheduled times rounded to the minute and offer this morning, this afternoon and Monday presets (bulwarkmail/webmail#1390) --- src/lib/__tests__/compose-schedule.test.ts | 83 +++++++++++++++++++++ src/lib/compose-schedule.ts | 86 ++++++++++++++++++++++ src/screens/ComposeScreen.tsx | 13 ++-- src/screens/ScheduledScreen.tsx | 12 +-- 4 files changed, 183 insertions(+), 11 deletions(-) create mode 100644 src/lib/__tests__/compose-schedule.test.ts create mode 100644 src/lib/compose-schedule.ts diff --git a/src/lib/__tests__/compose-schedule.test.ts b/src/lib/__tests__/compose-schedule.test.ts new file mode 100644 index 00000000..384555b7 --- /dev/null +++ b/src/lib/__tests__/compose-schedule.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { formatScheduledSendAt, schedulePresetLabel, schedulePresets } from '../compose-schedule'; + +// Ported from the webmail's lib/__tests__/schedule-presets.test.ts and +// scheduled-send-label.test.ts. Local dates, so the expectations hold in any +// time zone. +const local = (y: number, mo: number, d: number, h = 0, mi = 0, s = 0) => new Date(y, mo - 1, d, h, mi, s); +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, rel), 'utf8'); + +describe('schedulePresets', () => { + it('offers tomorrow morning and afternoon and next Monday morning', () => { + // Wednesday 2026-10-14, 18:30 + const presets = schedulePresets(local(2026, 10, 14, 18, 30)); + expect(presets.map((p) => [p.key, p.date.getTime()])).toEqual([ + ['tomorrow_morning', local(2026, 10, 15, 8).getTime()], + ['tomorrow_afternoon', local(2026, 10, 15, 13).getTime()], + ['monday_morning', local(2026, 10, 19, 8).getTime()], + ]); + }); + + it('skips Monday on a Sunday, when it is tomorrow anyway', () => { + expect(schedulePresets(local(2026, 10, 11, 18)).map((p) => p.key)).toEqual(['tomorrow_morning', 'tomorrow_afternoon']); + }); + + it('picks the Monday a week ahead on a Monday', () => { + const presets = schedulePresets(local(2026, 10, 12, 9)); + expect(presets.find((p) => p.key === 'monday_morning')?.date.getTime()).toBe(local(2026, 10, 19, 8).getTime()); + }); + + it('offers this morning and afternoon after midnight, while they are ahead (#1390)', () => { + // Thursday 2026-10-15, 01:30 + const presets = schedulePresets(local(2026, 10, 15, 1, 30)); + expect(presets.slice(0, 3).map((p) => [p.key, p.date.getTime()])).toEqual([ + ['this_morning', local(2026, 10, 15, 8).getTime()], + ['this_afternoon', local(2026, 10, 15, 13).getTime()], + ['tomorrow_morning', local(2026, 10, 16, 8).getTime()], + ]); + }); + + it('drops a time of today that is past or about to be', () => { + expect(schedulePresets(local(2026, 10, 15, 9)).map((p) => p.key)[0]).toBe('this_afternoon'); + expect(schedulePresets(local(2026, 10, 15, 12, 58)).map((p) => p.key)[0]).toBe('tomorrow_morning'); + }); + + it('labels every preset', () => { + const t = (_key: string, fallback?: string) => fallback ?? ''; + expect(schedulePresets(local(2026, 10, 15, 1)).map((p) => schedulePresetLabel(p.key, t))) + .toEqual(['This morning', 'This afternoon', 'Tomorrow morning', 'Tomorrow afternoon', 'Monday morning']); + }); +}); + +describe('formatScheduledSendAt', () => { + const today = local(2026, 10, 11, 18, 0); + + it('shows 20:00 for a release one second before eight', () => { + const label = formatScheduledSendAt(local(2026, 10, 11, 19, 59, 59).toISOString(), '24h', 'en', today); + expect(label).toContain('20:00'); + expect(label).not.toContain('19:59'); + }); + + it('shows only the time for a release later today', () => { + expect(formatScheduledSendAt(local(2026, 10, 11, 21, 30).toISOString(), '24h', 'en', today)).toBe('21:30'); + }); + + it('includes the date for a release on another day', () => { + const label = formatScheduledSendAt(local(2026, 10, 12, 7, 59, 59).toISOString(), '24h', 'en', today); + expect(label).toContain('08:00'); + expect(label).toMatch(/12/); + }); +}); + +describe('schedule wiring', () => { + it('offers the presets in the composer and in Reschedule, and labels times rounded', () => { + const compose = read('../../screens/ComposeScreen.tsx'); + expect(compose).toMatch(/\.\.\.webmailSchedulePresets\(now\)\.map\(/); + expect(compose).toMatch(/time: formatScheduledSendAt\(when\.toISOString\(\), timeFormat, locale\)/); + const scheduled = read('../../screens/ScheduledScreen.tsx'); + expect(scheduled).toMatch(/\.\.\.schedulePresets\(now\)\.map\(/); + expect(scheduled).toMatch(/\(iso: string\) => formatScheduledSendAt\(iso, timeFormat, locale\)/); + }); +}); diff --git a/src/lib/compose-schedule.ts b/src/lib/compose-schedule.ts new file mode 100644 index 00000000..f9e3120e --- /dev/null +++ b/src/lib/compose-schedule.ts @@ -0,0 +1,86 @@ +// "Schedule send" presets and the release time of a scheduled message. +// Ports of the webmail's lib/schedule-presets.ts and +// lib/scheduled-send-label.ts. + +import { intlLocale } from './intl-locale'; + +export type SchedulePresetKey = 'this_morning' | 'this_afternoon' | 'tomorrow_morning' | 'tomorrow_afternoon' | 'monday_morning'; + +export interface SchedulePreset { + key: SchedulePresetKey; + date: Date; +} + +function at(base: Date, daysAhead: number, hour: number): Date { + const date = new Date(base); + date.setDate(date.getDate() + daysAhead); + date.setHours(hour, 0, 0, 0); + return date; +} + +/** How far ahead a time for today must be to be offered, so it is not past by the tap. */ +const TODAY_LEAD_MS = 5 * 60_000; + +/** + * One-tap times for "Schedule send": tomorrow at 8:00 and 13:00, and next + * Monday at 8:00. Monday is left out when it is tomorrow anyway (on a + * Sunday). Today's 8:00 and 13:00 come first while they are still ahead, so + * a message written after midnight can go out the same morning (#1390). + */ +export function schedulePresets(now: Date = new Date()): SchedulePreset[] { + const presets: SchedulePreset[] = [ + { key: 'this_morning' as const, date: at(now, 0, 8) }, + { key: 'this_afternoon' as const, date: at(now, 0, 13) }, + ].filter((preset) => preset.date.getTime() - now.getTime() >= TODAY_LEAD_MS); + presets.push( + { key: 'tomorrow_morning', date: at(now, 1, 8) }, + { key: 'tomorrow_afternoon', date: at(now, 1, 13) }, + ); + const daysToMonday = ((8 - now.getDay()) % 7) || 7; + if (daysToMonday > 1) presets.push({ key: 'monday_morning', date: at(now, daysToMonday, 8) }); + return presets; +} + +/** The label of a "Schedule send" preset (webmail email_composer.schedule_preset). */ +export function schedulePresetLabel( + key: SchedulePresetKey, + t: (key: string, fallback?: string) => string, +): string { + switch (key) { + case 'this_morning': return t('email_composer.schedule_preset.this_morning', 'This morning'); + case 'this_afternoon': return t('email_composer.schedule_preset.this_afternoon', 'This afternoon'); + case 'tomorrow_morning': return t('email_composer.schedule_preset.tomorrow_morning', 'Tomorrow morning'); + case 'tomorrow_afternoon': return t('email_composer.schedule_preset.tomorrow_afternoon', 'Tomorrow afternoon'); + case 'monday_morning': return t('email_composer.schedule_preset.monday_morning', 'Monday morning'); + } +} + +/** + * The release time of a scheduled message, for lists, banners and the + * "Scheduled" confirmation. + * + * The server can land a second before the minute the user picked (08:00 + * showed as 07:59), so the time is rounded to the nearest minute. A message + * that is not due today also shows its date; a bare "07:59" for tomorrow + * morning read like a time that had already passed. + */ +export function formatScheduledSendAt( + sendAt: string, + timeFormat: '12h' | '24h', + locale?: string, + now: Date = new Date(), +): string { + const ms = Date.parse(sendAt); + if (!Number.isFinite(ms)) return sendAt; + const rounded = new Date(Math.round(ms / 60_000) * 60_000); + const sameDay = rounded.toDateString() === now.toDateString(); + const time = { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' } as const; + const options: Intl.DateTimeFormatOptions = sameDay + ? time + : { ...time, weekday: 'short', month: 'short', day: 'numeric' }; + try { + return rounded.toLocaleString(intlLocale(locale), options); + } catch { + return rounded.toLocaleString(); + } +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index db5f1cb8..09f4bb98 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -69,6 +69,7 @@ import { shouldBlockEditorRemoteImages } from '../lib/editor-html'; import { placeholderWordsFor, unfilledPlaceholdersInMessage } from '../lib/placeholder-check'; import { sendTargetFor } from '../lib/compose-send-account'; import { findAttachmentKeyword } from '../lib/compose-send-checks'; +import { formatScheduledSendAt, schedulePresetLabel, schedulePresets as webmailSchedulePresets } from '../lib/compose-schedule'; import { inlineImageType } from '../lib/compose-inline-type'; import { attachmentFromFileNode } from '../lib/compose-files'; import { supportsFiles } from '../api/files'; @@ -2298,15 +2299,14 @@ export default function ComposeScreen({ route, navigation }: Props) { const schedulePresets = React.useMemo(() => { const now = new Date(); const inHours = (h: number) => new Date(now.getTime() + h * 3600 * 1000); - const tomorrowMorning = new Date(now); - tomorrowMorning.setDate(tomorrowMorning.getDate() + 1); - tomorrowMorning.setHours(8, 0, 0, 0); // Only offer times within the server's hold limit. const maxMs = jmapClient.getMaxDelayedSend() * 1000; return [ { label: t('email_composer.schedule_in_1h', 'In 1 hour'), date: inHours(1) }, { label: t('email_composer.schedule_in_3h', 'In 3 hours'), date: inHours(3) }, - { label: t('email_composer.schedule_tomorrow_morning', 'Tomorrow morning'), date: tomorrowMorning }, + // The webmail's: this morning and afternoon while still ahead (#1390), + // tomorrow at 8:00 and 13:00, next Monday at 8:00. + ...webmailSchedulePresets(now).map((preset) => ({ label: schedulePresetLabel(preset.key, t), date: preset.date })), ].filter((preset) => preset.date.getTime() - now.getTime() <= maxMs); // eslint-disable-next-line react-hooks/exhaustive-deps }, [t, scheduleSheetOpen]); @@ -2480,7 +2480,10 @@ export default function ComposeScreen({ route, navigation }: Props) { const when = result.sendAt ? new Date(result.sendAt) : scheduledAt; Alert.alert( t('email_composer.scheduled_title', 'Scheduled'), - t('email_composer.scheduled_body', 'Your message will be sent at {time}.', { time: formatWhen(when) }), + t('email_composer.scheduled_body', 'Your message will be sent at {time}.', { + // The server can land a second early: 08:00 read 07:59. + time: formatScheduledSendAt(when.toISOString(), timeFormat, locale), + }), ); } else if (result.scheduled) { // Undo-send window: the undo bar offers Undo / Send now. diff --git a/src/screens/ScheduledScreen.tsx b/src/screens/ScheduledScreen.tsx index 4a406f98..e380993d 100644 --- a/src/screens/ScheduledScreen.tsx +++ b/src/screens/ScheduledScreen.tsx @@ -22,7 +22,7 @@ import { useSettingsStore } from '../stores/settings-store'; import { useSendUndoStore } from '../stores/send-undo-store'; import { toast } from '../stores/toast-store'; import { draftContextFromEmail } from '../lib/draft-context'; -import { formatQuoteDate } from '../lib/quote-header'; +import { formatScheduledSendAt, schedulePresetLabel, schedulePresets } from '../lib/compose-schedule'; import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; @@ -93,8 +93,10 @@ export default function ScheduledScreen({ navigation }: Props) { }, []); useBackWhileFocused(!!viewing, closeView); + // Rounded to the minute (the server can land a second early: 08:00 read + // 07:59), with the date when it is not today (webmail scheduled-send-label). const formatWhen = React.useCallback( - (iso: string) => formatQuoteDate(iso, timeFormat, locale), + (iso: string) => formatScheduledSendAt(iso, timeFormat, locale), [timeFormat, locale], ); @@ -325,15 +327,13 @@ export default function ScheduledScreen({ navigation }: Props) { const reschedulePresets = React.useMemo(() => { const now = new Date(); const inHours = (h: number) => new Date(now.getTime() + h * 3600 * 1000); - const tomorrowMorning = new Date(now); - tomorrowMorning.setDate(tomorrowMorning.getDate() + 1); - tomorrowMorning.setHours(8, 0, 0, 0); // Only offer times within the server's hold limit. const maxMs = jmapClient.getMaxDelayedSend(rescheduleFor?.accountId) * 1000; return [ { label: t('email_composer.schedule_in_1h', 'In 1 hour'), date: inHours(1) }, { label: t('email_composer.schedule_in_3h', 'In 3 hours'), date: inHours(3) }, - { label: t('email_composer.schedule_tomorrow_morning', 'Tomorrow morning'), date: tomorrowMorning }, + // The composer's presets, the webmail's (lib/compose-schedule). + ...schedulePresets(now).map((preset) => ({ label: schedulePresetLabel(preset.key, t), date: preset.date })), ].filter((preset) => preset.date.getTime() - now.getTime() <= maxMs); // eslint-disable-next-line react-hooks/exhaustive-deps }, [t, rescheduleFor]);