/** Reminder date presets and recurrence picker interactions. */
import { _formatRepeatLabel, _toLocalDatetimeStr, _normalizeRepeat, _DAYS, _ORDINALS } from '../domain/recurrence.js';
export function createReminderPicker({
lifecycle,
_ensureNotificationPermission,
_esc,
_formatReminderTag,
_laterTodayDate,
_nextWeekDate,
_snapToRepeat,
_tomorrowDate,
bindMenuDismiss,
dismissOrRemove
}) {
function _wireReminderPicker(form) {
// Reminder bell : opens dropdown menu
const remindBtn = form.querySelector('.note-form-remind-btn');
const dueInput = form.querySelector('.note-form-due');
const repeatInput = form.querySelector('.note-form-repeat');
const tagsEl = form.querySelector('.note-form-reminder-tags');
function _renderReminderTag() {
if (!tagsEl) return;
const v = dueInput.value;
const rep = repeatInput.value || 'none';
if (!v) { tagsEl.innerHTML = ''; return; }
const label = _formatReminderTag(v);
const repLabel = rep !== 'none' ? ` · ${_formatRepeatLabel(rep, new Date(v))}` : '';
tagsEl.innerHTML = ``;
tagsEl.querySelector('.note-reminder-tag').addEventListener('click', (e) => {
if (e.target.classList.contains('note-reminder-tag-x')) {
dueInput.value = '';
repeatInput.value = 'none';
_renderReminderTag();
return;
}
_openReminderMenu(remindBtn || tagsEl, true);
});
}
function _openReminderMenu(anchor, isEdit = false) {
// Close any existing menu
document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove());
const menu = document.createElement('div');
menu.className = 'note-reminder-menu';
document.body.appendChild(menu);
const presetItems = [
{ label: 'Later today', sub: _laterTodayDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_laterTodayDate())) },
{ label: 'Tomorrow', sub: _tomorrowDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_tomorrowDate())) },
{ label: 'Next week', sub: _nextWeekDate().toLocaleDateString([], { weekday: 'short' }) + ' ' + _nextWeekDate().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), action: () => _setReminder(_toLocalDatetimeStr(_nextWeekDate())) },
{ label: 'Select date and time', sub: '', action: () => _pickCustomDate() },
];
// Sub-page state for the repeat picker. null = top page.
// 'weekly' | 'monthly' | 'monthly_nth'
let subMode = null;
// Temporary state for monthly_nth so user can click N then weekday (or vice versa)
// before committing.
let nthDraft = { n: 0, w: -1 };
const DAY_SHORT = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
function getNorm() {
if (!dueInput.value) return 'none';
return _normalizeRepeat(repeatInput.value || 'none', new Date(dueInput.value));
}
function commit(val) {
repeatInput.value = val;
_renderReminderTag();
menu.remove();
}
// Like commit, but first snaps dueInput.value forward to the next matching
// slot for the chosen recurrence. Use for weekly/monthly variants where the
// current due date may not match the chosen pattern (e.g. user picks
// "weekly on Mondays" while the date is a Wednesday).
function snapAndCommit(val) {
if (dueInput.value) {
const cur = new Date(dueInput.value);
const norm = _normalizeRepeat(val, cur);
const snapped = _snapToRepeat(cur, norm);
if (snapped) {
dueInput.value = _toLocalDatetimeStr(snapped);
if (remindBtn) remindBtn.classList.add('has-date');
}
}
commit(val);
}
function reposition() {
const rect = anchor.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const mw = menu.offsetWidth || 220;
const mh = menu.offsetHeight || 280;
let top = rect.bottom + 4;
let left = rect.left;
if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4);
if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8);
if (left < 8) left = 8;
menu.style.top = top + 'px';
menu.style.left = left + 'px';
}
function render() {
let html = '';
if (subMode === null) {
html += '
Remind me later
';
for (let i = 0; i < presetItems.length; i++) {
const it = presetItems[i];
html += ``;
}
if (isEdit && dueInput.value) {
const norm = getNorm();
html += '';
html += '
Repeat
';
// None
html += ``;
// Daily
html += ``;
// Weekly →
{
const isW = norm.startsWith('weekly:');
const wd = isW ? parseInt(norm.split(':')[1], 10) : null;
const sub = isW && !isNaN(wd) ? `${_DAYS[wd]}` : '';
html += ``;
}
// Monthly →
{
const isM = norm.startsWith('monthly:');
const sub = isM ? `${_monthlyShortDescriptor(norm)}` : '';
html += ``;
}
// Yearly
html += ``;
}
} else if (subMode === 'weekly') {
const norm = getNorm();
const curWd = norm.startsWith('weekly:') ? parseInt(norm.split(':')[1], 10) : -1;
html += ``;
html += '
Weekly on…
';
html += '
';
for (let i = 0; i < 7; i++) {
html += ``;
}
html += '
';
} else if (subMode === 'monthly') {
const norm = getNorm();
const dueDate = new Date(dueInput.value);
const dayN = dueDate.getDate();
html += ``;
html += '
Monthly on…
';
// Day N : uses the chosen date's day. Always offered.
const dayVal = `monthly:day:${dayN}`;
html += ``;
// Nth weekday →
{
const isNth = norm.startsWith('monthly:nth:');
const sub = isNth ? `${_monthlyShortDescriptor(norm)}` : '';
html += ``;
}
} else if (subMode === 'monthly_nth') {
// Pick ordinal (1..4) and weekday (0..6); commit when both chosen.
html += ``;
html += '
Nth weekday of month
';
html += '
Which one
';
html += '
';
for (let i = 1; i <= 4; i++) {
html += ``;
}
html += '
';
html += '
Weekday
';
html += '
';
for (let i = 0; i < 7; i++) {
html += ``;
}
html += '
';
html += '';
const ready = nthDraft.n > 0 && nthDraft.w >= 0;
const lbl = ready ? `Save: ${_ORDINALS[nthDraft.n - 1]} ${_DAYS[nthDraft.w]}` : 'Pick week and weekday';
html += ``;
}
menu.innerHTML = html;
reposition();
wire();
}
function wire() {
menu.querySelectorAll('[data-action]').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const a = el.dataset.action;
if (a === 'preset') {
const it = presetItems[parseInt(el.dataset.i, 10)];
it.action();
menu.remove();
} else if (a === 'set') {
snapAndCommit(el.dataset.val);
} else if (a === 'sub') {
subMode = el.dataset.sub;
// Seed nth draft from saved value only on first entry : preserve
// in-progress picks across back-trips (Nth → back → Nth again).
if (subMode === 'monthly_nth' && nthDraft.n === 0 && nthDraft.w === -1) {
const norm = getNorm();
const m = norm.match(/^monthly:nth:(\d):(\d)$/);
if (m) nthDraft = { n: parseInt(m[1], 10), w: parseInt(m[2], 10) };
}
render();
} else if (a === 'back') {
subMode = null;
render();
} else if (a === 'back-monthly') {
subMode = 'monthly';
render();
} else if (a === 'weekly-pick') {
snapAndCommit(`weekly:${el.dataset.wd}`);
} else if (a === 'nth-n') {
nthDraft.n = parseInt(el.dataset.n, 10);
render();
} else if (a === 'nth-w') {
nthDraft.w = parseInt(el.dataset.wd, 10);
render();
} else if (a === 'nth-save') {
if (nthDraft.n > 0 && nthDraft.w >= 0) {
snapAndCommit(`monthly:nth:${nthDraft.n}:${nthDraft.w}`);
}
}
});
});
}
render();
// Click outside to close (single global handler attached after first paint)
setTimeout(() => {
const close = (e) => {
if (!menu.isConnected) { document.removeEventListener('click', close); return; }
if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); }
};
if (menu.isConnected) lifecycle.listen(menu, document, 'click', close);
}, 0);
}
function _monthlyShortDescriptor(norm) {
const parts = norm.split(':');
if (parts[1] === 'day') return `Day ${parts[2]}`;
if (parts[1] === 'nth') {
const n = parseInt(parts[2], 10);
const wd = parseInt(parts[3], 10);
return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd].slice(0, 3)}`;
}
if (parts[1] === 'last') {
const wd = parseInt(parts[2], 10);
return `Last ${_DAYS[wd].slice(0, 3)}`;
}
return '';
}
function _setReminder(datetimeLocalStr) {
dueInput.value = datetimeLocalStr;
if (remindBtn) {
remindBtn.classList.add('has-date');
// Jingle the bell. CSS handles the animation; remove + reflow + re-add
// so it replays every time the user sets/changes a reminder.
const _bell = remindBtn.querySelector('svg');
if (_bell) {
_bell.classList.remove('jingling');
void _bell.offsetWidth;
_bell.classList.add('jingling');
setTimeout(() => _bell.classList.remove('jingling'), 700);
}
}
_renderReminderTag();
_ensureNotificationPermission();
}
function _pickCustomDate() {
// Replace the dropdown menu with a small inline picker
document.querySelectorAll('.note-reminder-menu').forEach(dismissOrRemove);
const menu = document.createElement('div');
menu.className = 'note-reminder-menu';
const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate());
menu.innerHTML = `
Pick date and time
`;
document.body.appendChild(menu);
// Position next to the bell button
const anchor = remindBtn || form.querySelector('.note-form-reminder-tags');
const rect = anchor.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const mw = menu.offsetWidth || 240;
const mh = menu.offsetHeight || 200;
let top = rect.bottom + 4;
let left = rect.left;
if (top + mh > vh - 8) top = Math.max(8, rect.top - mh - 4);
if (left + mw > vw - 8) left = Math.max(8, vw - mw - 8);
if (left < 8) left = 8;
menu.style.top = top + 'px';
menu.style.left = left + 'px';
const dInput = menu.querySelector('.note-reminder-date-input');
dInput.value = initial;
dInput.focus();
if (typeof dInput.showPicker === 'function') {
try { dInput.showPicker(); } catch {}
}
const close = bindMenuDismiss(menu, () => { menu.remove(); });
menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => {
if (dInput.value) _setReminder(dInput.value);
close();
});
}
if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); });
_renderReminderTag();
}
return { _wireReminderPicker };
}