fix: show scheduled times rounded to the minute and offer this morning, this afternoon and Monday presets (bulwarkmail/webmail#1390)

This commit is contained in:
Linus Rath
2026-10-31 03:46:43 +01:00
parent 1cc4ccca8e
commit 8fe5798bf5
4 changed files with 183 additions and 11 deletions
@@ -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\)/);
});
});
+86
View File
@@ -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();
}
}
+8 -5
View File
@@ -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.
+6 -6
View File
@@ -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]);