feat: add flexible reminder options for birthdays
Add support for customizable birthday reminders with preset offsets (none, at time, 15min, 1h, 1d, 2d, 1w, 2w) and custom intervals. Users can now configure when to be reminded of upcoming birthdays. - Add migration 31: reminder_offset, reminder_custom_amount, reminder_custom_unit to birthdays table - Update POST/PUT /birthdays routes to accept reminder fields - Add getOffsetMinutes() helper in birthday service - Update birthdayReminderAt() to calculate reminder time with offset - Modify syncBirthdayReminder() to handle empty offset (no reminder) - Add renderBirthdayReminderSection() UI component - Move reminder-custom CSS from calendar.css to reminders.css - Add protocol check to service worker (non-http protocol guard) All translations already present in de.json. Tests: 109 passing, 0 failing. Co-Authored-By: Rafael Foster <rafaelfoster@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1180,6 +1180,15 @@ const MIGRATIONS = [
|
||||
CREATE INDEX idx_contact_addresses_contact ON contact_addresses(contact_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 31,
|
||||
description: 'Advanced reminder options for birthdays',
|
||||
up: `
|
||||
ALTER TABLE birthdays ADD COLUMN reminder_offset TEXT;
|
||||
ALTER TABLE birthdays ADD COLUMN reminder_custom_amount INTEGER;
|
||||
ALTER TABLE birthdays ADD COLUMN reminder_custom_unit TEXT;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -75,9 +75,18 @@ router.post('/', (req, res) => {
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||
|
||||
const result = db.get().prepare(`
|
||||
INSERT INTO birthdays (name, birth_date, notes, photo_data, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(vName.value, vBirthDate.value, vNotes.value, vPhoto.value ?? null, req.authUserId || req.session.userId);
|
||||
INSERT INTO birthdays (name, birth_date, notes, photo_data, created_by, reminder_offset, reminder_custom_amount, reminder_custom_unit)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
vName.value,
|
||||
vBirthDate.value,
|
||||
vNotes.value,
|
||||
vPhoto.value ?? null,
|
||||
req.authUserId || req.session.userId,
|
||||
req.body.reminder_offset ?? null,
|
||||
req.body.reminder_custom_amount ?? null,
|
||||
req.body.reminder_custom_unit ?? null
|
||||
);
|
||||
|
||||
const birthday = loadBirthday(result.lastInsertRowid);
|
||||
const synced = db.transaction(() => syncBirthdayArtifacts(db.get(), birthday));
|
||||
@@ -111,6 +120,9 @@ router.put('/:id', (req, res) => {
|
||||
birth_date = COALESCE(?, birth_date),
|
||||
notes = ?,
|
||||
photo_data = ?,
|
||||
reminder_offset = ?,
|
||||
reminder_custom_amount = ?,
|
||||
reminder_custom_unit = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
@@ -118,6 +130,9 @@ router.put('/:id', (req, res) => {
|
||||
req.body.birth_date ?? null,
|
||||
req.body.notes !== undefined ? (req.body.notes?.trim() || null) : existing.notes,
|
||||
req.body.photo_data !== undefined ? (vPhoto.value ?? null) : existing.photo_data,
|
||||
req.body.reminder_offset !== undefined ? req.body.reminder_offset : existing.reminder_offset,
|
||||
req.body.reminder_custom_amount !== undefined ? req.body.reminder_custom_amount : existing.reminder_custom_amount,
|
||||
req.body.reminder_custom_unit !== undefined ? req.body.reminder_custom_unit : existing.reminder_custom_unit,
|
||||
id,
|
||||
);
|
||||
|
||||
|
||||
@@ -43,9 +43,22 @@ function daysUntilBirthday(birthDate, from = new Date()) {
|
||||
return Math.round((nextUtc - todayUtc) / 86400000);
|
||||
}
|
||||
|
||||
function birthdayReminderAt(birthDate, from = new Date()) {
|
||||
function getOffsetMinutes(birthday) {
|
||||
if (birthday.reminder_offset === 'custom') {
|
||||
const amount = parseInt(birthday.reminder_custom_amount, 10) || 1;
|
||||
const unit = birthday.reminder_custom_unit || 'days';
|
||||
if (unit === 'weeks') return amount * 10080;
|
||||
if (unit === 'days') return amount * 1440;
|
||||
if (unit === 'hours') return amount * 60;
|
||||
return amount;
|
||||
}
|
||||
return parseInt(birthday.reminder_offset, 10) || 0;
|
||||
}
|
||||
|
||||
function birthdayReminderAt(birthDate, offsetMin = 0, from = new Date()) {
|
||||
const next = nextBirthdayDate(birthDate, from);
|
||||
return `${next}T12:00:00Z`;
|
||||
const baseTime = new Date(`${next}T12:00:00Z`).getTime();
|
||||
return new Date(baseTime - (offsetMin || 0) * 60000).toISOString();
|
||||
}
|
||||
|
||||
function eventTitle(name) {
|
||||
@@ -125,7 +138,16 @@ function syncBirthdayCalendarEvent(database, birthday) {
|
||||
function syncBirthdayReminder(database, birthday, from = new Date()) {
|
||||
if (!birthday.calendar_event_id) return null;
|
||||
|
||||
const desired = birthdayReminderAt(birthday.birth_date, from);
|
||||
if (birthday.reminder_offset === '') {
|
||||
database.prepare(`
|
||||
DELETE FROM reminders
|
||||
WHERE entity_type = 'event' AND entity_id = ? AND created_by = ?
|
||||
`).run(birthday.calendar_event_id, birthday.created_by);
|
||||
return null;
|
||||
}
|
||||
|
||||
const offsetMin = getOffsetMinutes(birthday);
|
||||
const desired = birthdayReminderAt(birthday.birth_date, offsetMin, from);
|
||||
const existing = database.prepare(`
|
||||
SELECT * FROM reminders
|
||||
WHERE entity_type = 'event' AND entity_id = ? AND created_by = ?
|
||||
|
||||
Reference in New Issue
Block a user