feat(settings): configurable reminder send-time (plan Tier 5)

The daily reminder job ran at a hardcoded 6 AM. Make the hour configurable:
- dailyWorker: resolveReminderHour() reads the global 'reminder_hour' setting
  (clamp 0–23, default 6) as the single source for both the cron expression and
  the next-run display; the task is stored so rescheduleDailyWorker() can restart
  it live (defensively wrapped so a bad value can't take the worker down).
- notifications admin GET/PUT expose reminder_hour; PUT clamps, persists, and
  live-reschedules the worker.
- Admin Email Notifications card: a "Reminder send time" select (account-wide).

It's a global setting by design — there's one shared daily job, so a per-user
hour would require an hourly-worker refactor (out of scope) and would otherwise
be a dead setting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 16:10:25 -05:00
parent 0e6f04c7bc
commit 1f57b22ff8
3 changed files with 78 additions and 12 deletions

View File

@ -23,6 +23,7 @@ interface EmailConfig {
smtp_password: string;
allow_user_config: boolean;
global_recipient: string;
reminder_hour: string;
}
const DEFAULTS: EmailConfig = {
@ -33,8 +34,15 @@ const DEFAULTS: EmailConfig = {
smtp_username: '', smtp_password: '',
allow_user_config: false,
global_recipient: '',
reminder_hour: '6',
};
// "6:00 AM" … "11:00 PM" for the hour select.
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({
value: String(h),
label: `${(h % 12) || 12}:00 ${h < 12 ? 'AM' : 'PM'}`,
}));
export default function EmailNotifCard() {
const [cfg, setCfg] = useState<EmailConfig>(DEFAULTS);
const [loading, setLoading] = useState(true);
@ -95,6 +103,22 @@ export default function EmailNotifCard() {
<Toggle checked={cfg.enabled} onChange={v => set('enabled', v)} label="Enable email notifications" />
</div>
<FieldRow label="Reminder send time">
<div className="flex flex-col items-end gap-1">
<Select value={cfg.reminder_hour} onValueChange={v => set('reminder_hour', v)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HOUR_OPTIONS.map(o => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[11px] text-muted-foreground">Daily reminder emails applies account-wide.</span>
</div>
</FieldRow>
<div className="border-t border-border" />
<div className="space-y-4">

View File

@ -15,10 +15,11 @@ router.get('/admin', requireAuth, requireAdmin, (req, res) => {
'notify_smtp_enabled', 'notify_sender_name', 'notify_sender_address',
'notify_smtp_host', 'notify_smtp_port', 'notify_smtp_encryption',
'notify_smtp_self_signed', 'notify_smtp_username', 'notify_smtp_password',
'notify_allow_user_config', 'notify_global_recipient',
'notify_allow_user_config', 'notify_global_recipient', 'reminder_hour',
];
const settings = {};
for (const k of keys) settings[k] = getSetting(k) || '';
if (!settings.reminder_hour) settings.reminder_hour = '6';
// Mask password in response
if (settings.notify_smtp_password) settings.notify_smtp_password = '••••••••';
res.json(settings);
@ -39,6 +40,17 @@ router.put('/admin', requireAuth, requireAdmin, (req, res) => {
if (req.body.notify_smtp_password && !req.body.notify_smtp_password.startsWith('•')) {
setSetting('notify_smtp_password', encryptSecret(req.body.notify_smtp_password));
}
// Reminder send-hour (023, global): persist clamped + live-reschedule the cron.
if (req.body.reminder_hour !== undefined) {
const h = parseInt(req.body.reminder_hour, 10);
const hour = Number.isInteger(h) && h >= 0 && h <= 23 ? h : 6;
setSetting('reminder_hour', String(hour));
try {
require('../workers/dailyWorker').rescheduleDailyWorker();
} catch (err) {
console.error('[notifications] Failed to reschedule daily worker:', err.message);
}
}
res.json({ success: true });
});

View File

@ -1,7 +1,7 @@
'use strict';
const cron = require('node-cron');
const { getDb } = require('../db/database');
const { getDb, getSetting } = require('../db/database');
const { buildTrackerRow, getCycleRange } = require('../services/statusService');
const { pruneExpiredSessions } = require('../services/authService');
const { pruneExpiredChallenges: pruneWebAuthnChallenges } = require('../services/webauthnService');
@ -14,11 +14,17 @@ const {
} = require('../services/statusRuntime');
const { localDateString, localDateStringDaysAgo } = require('../utils/dates.mts');
const DAILY_CRON_HOUR = 6;
// The hour (023) the single daily job runs — configurable via the global
// 'reminder_hour' setting, defaulting to 6 AM. One source of truth for both the
// cron expression and the next-run display.
function resolveReminderHour() {
const parsed = parseInt(getSetting('reminder_hour') ?? '6', 10);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 23 ? parsed : 6;
}
function nextDailyRunIso(from = new Date()) {
const next = new Date(from);
next.setHours(DAILY_CRON_HOUR, 0, 0, 0);
next.setHours(resolveReminderHour(), 0, 0, 0);
if (next <= from) next.setDate(next.getDate() + 1);
return next.toISOString();
}
@ -113,6 +119,35 @@ async function runDailyTasks() {
console.log(`[worker] Daily tasks ran at ${todayStr}`);
}
let scheduledTask = null;
// (Re)create the daily cron at the configured hour. Stops any prior task first.
function scheduleDaily() {
if (scheduledTask) {
scheduledTask.stop();
scheduledTask = null;
}
const hour = resolveReminderHour();
scheduledTask = cron.schedule(`0 ${hour} * * *`, () => {
runDailyTasks().catch(err => {
markWorkerError(err, nextDailyRunIso());
console.error('[worker] Daily task error:', err);
});
});
}
// Called when the 'reminder_hour' setting changes so it applies without a
// restart. Defensive: a failure here must never take the worker down.
function rescheduleDailyWorker() {
try {
scheduleDaily();
markWorkerStarted(nextDailyRunIso());
console.log(`[worker] Rescheduled daily tasks to ${resolveReminderHour()}:00`);
} catch (err) {
console.error('[worker] Failed to reschedule daily worker:', err.message);
}
}
function start() {
markWorkerStarted(nextDailyRunIso());
@ -122,13 +157,8 @@ function start() {
console.error('[worker] Startup task error:', err);
});
// Run every day at 6:00 AM
cron.schedule('0 6 * * *', () => {
runDailyTasks().catch(err => {
markWorkerError(err, nextDailyRunIso());
console.error('[worker] Daily task error:', err);
});
});
// Run every day at the configured hour (default 6 AM)
scheduleDaily();
}
module.exports = { start, runDailyTasks, nextDailyRunIso };
module.exports = { start, runDailyTasks, nextDailyRunIso, rescheduleDailyWorker };