feat(reminders): custom date/time option in Remind Me
The Remind Me dialog only offered four fixed presets, so you couldn't set a reminder for an arbitrary time. Add a "Custom time…" option that reveals date + time pickers (validated >= 1 minute in the future) and sets the reminder at that absolute timestamp. Also extract the local date/time <input> helpers (toLocalDate, toLocalTime, parseLocalDateTime, pickerInputStyle) into a shared, unit-tested utils/datetimeInput.ts and reuse them in ScheduleMessageModal (deduped from an inline copy) — identical output, now covered by tests. Documents the previously-undocumented Message Reminders feature in LOTUS_FEATURES.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { scheduleMessage } from '../../utils/scheduledMessages';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { toLocalDate, toLocalTime, parseLocalDateTime, pickerInputStyle } from '../../utils/datetimeInput';
|
||||
|
||||
interface ScheduleMessageModalProps {
|
||||
roomId: string;
|
||||
@@ -65,32 +66,6 @@ function formatSendAt(sendAt: Date): string {
|
||||
return `${sendAt.toLocaleDateString()} at ${timeStr}`;
|
||||
}
|
||||
|
||||
function toLocalDate(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
function toLocalTime(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
// Shared style for date/time inputs — dark-mode calendar/clock popup via colorScheme.
|
||||
const pickerInputStyle = (c: typeof color, cfg: typeof config): React.CSSProperties => ({
|
||||
background: c.SurfaceVariant.Container,
|
||||
color: c.SurfaceVariant.OnContainer,
|
||||
border: `${cfg.borderWidth.B300} solid ${c.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: cfg.radii.R300,
|
||||
padding: `${cfg.space.S200} ${cfg.space.S300}`,
|
||||
fontSize: '0.875rem',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit',
|
||||
// Hint browser to render the calendar/clock popup in dark mode
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
|
||||
export function ScheduleMessageModal({
|
||||
roomId,
|
||||
initialBody,
|
||||
@@ -119,11 +94,10 @@ export function ScheduleMessageModal({
|
||||
const [dateValue, setDateValue] = useState<string>(() => toLocalDate(def));
|
||||
const [timeValue, setTimeValue] = useState<string>(() => toLocalTime(def));
|
||||
|
||||
const getSendAt = useCallback((): Date | null => {
|
||||
if (!dateValue || !timeValue) return null;
|
||||
const dt = new Date(`${dateValue}T${timeValue}:00`);
|
||||
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||
}, [dateValue, timeValue]);
|
||||
const getSendAt = useCallback(
|
||||
(): Date | null => parseLocalDateTime(dateValue, timeValue),
|
||||
[dateValue, timeValue],
|
||||
);
|
||||
|
||||
const [preview, setPreview] = useState<{ label: string; relative: string } | null>(null);
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ import {
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useReminders } from '../../../hooks/useReminders';
|
||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
import {
|
||||
toLocalDate,
|
||||
toLocalTime,
|
||||
parseLocalDateTime,
|
||||
pickerInputStyle,
|
||||
} from '../../../utils/datetimeInput';
|
||||
|
||||
type RemindMeDialogProps = {
|
||||
roomId: string;
|
||||
@@ -40,14 +46,26 @@ function getPresets(): Array<{ label: string; ms: number }> {
|
||||
];
|
||||
}
|
||||
|
||||
// Default custom pick: 1 hour from now, rounded up to the nearest 5 minutes.
|
||||
function defaultCustomDate(): Date {
|
||||
const d = new Date(Date.now() + 60 * 60 * 1000);
|
||||
d.setSeconds(0, 0);
|
||||
d.setMinutes(Math.ceil(d.getMinutes() / 5) * 5);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
|
||||
const modalStyle = useModalStyle(320);
|
||||
const { addReminder } = useReminders();
|
||||
const presets = useMemo(() => getPresets(), []);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const def = useMemo(() => defaultCustomDate(), []);
|
||||
const [dateValue, setDateValue] = useState(() => toLocalDate(def));
|
||||
const [timeValue, setTimeValue] = useState(() => toLocalTime(def));
|
||||
|
||||
const handlePick = async (ms: number) => {
|
||||
const commit = async (timestamp: number) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@@ -55,7 +73,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
await addReminder({
|
||||
roomId,
|
||||
eventId,
|
||||
timestamp: Date.now() + ms,
|
||||
timestamp,
|
||||
message: previewText || 'Reminder',
|
||||
});
|
||||
onClose();
|
||||
@@ -65,6 +83,23 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
}
|
||||
};
|
||||
|
||||
const handlePick = (ms: number) => commit(Date.now() + ms);
|
||||
|
||||
const customDate = parseLocalDateTime(dateValue, timeValue);
|
||||
const customValid = !!customDate && customDate.getTime() - Date.now() >= 60_000;
|
||||
|
||||
const handleCustom = () => {
|
||||
if (!customDate) {
|
||||
setError('Please select a valid date and time.');
|
||||
return;
|
||||
}
|
||||
if (customDate.getTime() - Date.now() < 60_000) {
|
||||
setError('Reminder time must be at least 1 minute in the future.');
|
||||
return;
|
||||
}
|
||||
commit(customDate.getTime());
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
@@ -127,6 +162,70 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{customOpen ? (
|
||||
<Box direction="Column" gap="200" style={{ paddingTop: config.space.S100 }}>
|
||||
<Box gap="200">
|
||||
<Box direction="Column" gap="100" style={{ flex: 1 }}>
|
||||
<Text as="label" htmlFor="remind-date" size="T200" priority="400">
|
||||
Date
|
||||
</Text>
|
||||
<input
|
||||
id="remind-date"
|
||||
type="date"
|
||||
value={dateValue}
|
||||
min={toLocalDate(new Date())}
|
||||
disabled={busy}
|
||||
onChange={(e) => setDateValue(e.target.value)}
|
||||
style={pickerInputStyle(color, config)}
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100" style={{ flex: 1 }}>
|
||||
<Text as="label" htmlFor="remind-time" size="T200" priority="400">
|
||||
Time
|
||||
</Text>
|
||||
<input
|
||||
id="remind-time"
|
||||
type="time"
|
||||
value={timeValue}
|
||||
disabled={busy}
|
||||
onChange={(e) => setTimeValue(e.target.value)}
|
||||
style={pickerInputStyle(color, config)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
{!customValid && (dateValue || timeValue) && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
Must be at least 1 minute in the future
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
radii="300"
|
||||
disabled={busy || !customValid}
|
||||
onClick={handleCustom}
|
||||
>
|
||||
<Text size="B300">Set reminder</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setCustomOpen(true);
|
||||
}}
|
||||
before={<Icon src={Icons.Clock} size="100" />}
|
||||
>
|
||||
<Text size="B300">Custom time…</Text>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text
|
||||
size="T200"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { toLocalDate, toLocalTime, parseLocalDateTime } from './datetimeInput';
|
||||
|
||||
// All helpers work in the LOCAL timezone. We construct Dates with the local
|
||||
// component constructor so the assertions hold regardless of where tests run.
|
||||
|
||||
test('toLocalDate: zero-pads month and day', () => {
|
||||
assert.equal(toLocalDate(new Date(2026, 0, 5, 9, 3)), '2026-01-05');
|
||||
assert.equal(toLocalDate(new Date(2026, 11, 25, 0, 0)), '2026-12-25');
|
||||
});
|
||||
|
||||
test('toLocalTime: zero-pads hours and minutes (24h)', () => {
|
||||
assert.equal(toLocalTime(new Date(2026, 0, 5, 9, 3)), '09:03');
|
||||
assert.equal(toLocalTime(new Date(2026, 0, 5, 23, 59)), '23:59');
|
||||
assert.equal(toLocalTime(new Date(2026, 0, 5, 0, 0)), '00:00');
|
||||
});
|
||||
|
||||
test('toLocalDate/toLocalTime round-trip through parseLocalDateTime', () => {
|
||||
const d = new Date(2026, 5, 15, 14, 30, 0, 0);
|
||||
const parsed = parseLocalDateTime(toLocalDate(d), toLocalTime(d));
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.getTime(), d.getTime());
|
||||
});
|
||||
|
||||
test('parseLocalDateTime: returns null for missing parts', () => {
|
||||
assert.equal(parseLocalDateTime('', '09:00'), null);
|
||||
assert.equal(parseLocalDateTime('2026-01-05', ''), null);
|
||||
assert.equal(parseLocalDateTime('', ''), null);
|
||||
});
|
||||
|
||||
test('parseLocalDateTime: returns null for an invalid combination', () => {
|
||||
assert.equal(parseLocalDateTime('not-a-date', '09:00'), null);
|
||||
assert.equal(parseLocalDateTime('2026-13-40', '09:00'), null);
|
||||
});
|
||||
|
||||
test('parseLocalDateTime: valid input yields the local wall-clock time', () => {
|
||||
const parsed = parseLocalDateTime('2026-01-05', '09:03');
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.getFullYear(), 2026);
|
||||
assert.equal(parsed.getMonth(), 0);
|
||||
assert.equal(parsed.getDate(), 5);
|
||||
assert.equal(parsed.getHours(), 9);
|
||||
assert.equal(parsed.getMinutes(), 3);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { color as foldsColor, config as foldsConfig } from 'folds';
|
||||
|
||||
const pad = (n: number): string => String(n).padStart(2, '0');
|
||||
|
||||
// Format a Date as the value string expected by <input type="date"> in the
|
||||
// user's LOCAL timezone. (toISOString would shift to UTC and can land on the
|
||||
// wrong calendar day.)
|
||||
export function toLocalDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
// Format a Date as the value string expected by <input type="time"> (local, HH:mm).
|
||||
export function toLocalTime(date: Date): string {
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
// Parse the local date + time <input> values back into a Date. Returns null when
|
||||
// either value is missing or the combination is invalid (e.g. a partial entry).
|
||||
export function parseLocalDateTime(dateValue: string, timeValue: string): Date | null {
|
||||
if (!dateValue || !timeValue) return null;
|
||||
const dt = new Date(`${dateValue}T${timeValue}:00`);
|
||||
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||
}
|
||||
|
||||
// Shared style for date/time <input>s — matches the app's surface tokens and
|
||||
// hints a dark-mode calendar/clock popup via colorScheme.
|
||||
export function pickerInputStyle(
|
||||
c: typeof foldsColor = foldsColor,
|
||||
cfg: typeof foldsConfig = foldsConfig,
|
||||
): CSSProperties {
|
||||
return {
|
||||
background: c.SurfaceVariant.Container,
|
||||
color: c.SurfaceVariant.OnContainer,
|
||||
border: `${cfg.borderWidth.B300} solid ${c.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: cfg.radii.R300,
|
||||
padding: `${cfg.space.S200} ${cfg.space.S300}`,
|
||||
fontSize: '0.875rem',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit',
|
||||
colorScheme: 'dark',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user