Adding Birthday tracking feature - to compete with FamilyWall
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
const BIRTHDAY_COLOR = '#E11D48';
|
||||
const BIRTHDAY_RRULE = 'FREQ=YEARLY;INTERVAL=1';
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function leapYear(year) {
|
||||
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
||||
}
|
||||
|
||||
function normalizedMonthDay(birthDate, year) {
|
||||
const [, monthStr, dayStr] = String(birthDate).split('-');
|
||||
const month = parseInt(monthStr, 10);
|
||||
let day = parseInt(dayStr, 10);
|
||||
if (month === 2 && day === 29 && !leapYear(year)) day = 28;
|
||||
return `${year}-${pad2(month)}-${pad2(day)}`;
|
||||
}
|
||||
|
||||
function nextBirthdayDate(birthDate, from = new Date()) {
|
||||
const now = from instanceof Date ? from : new Date(from);
|
||||
const thisYear = normalizedMonthDay(birthDate, now.getFullYear());
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
return thisYear >= today
|
||||
? thisYear
|
||||
: normalizedMonthDay(birthDate, now.getFullYear() + 1);
|
||||
}
|
||||
|
||||
function nextBirthdayAge(birthDate, from = new Date()) {
|
||||
const next = nextBirthdayDate(birthDate, from);
|
||||
return parseInt(next.slice(0, 4), 10) - parseInt(String(birthDate).slice(0, 4), 10);
|
||||
}
|
||||
|
||||
function daysUntilBirthday(birthDate, from = new Date()) {
|
||||
const now = from instanceof Date ? from : new Date(from);
|
||||
const next = nextBirthdayDate(birthDate, now);
|
||||
const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const nextUtc = Date.UTC(
|
||||
parseInt(next.slice(0, 4), 10),
|
||||
parseInt(next.slice(5, 7), 10) - 1,
|
||||
parseInt(next.slice(8, 10), 10),
|
||||
);
|
||||
return Math.round((nextUtc - todayUtc) / 86400000);
|
||||
}
|
||||
|
||||
function birthdayReminderAt(birthDate, from = new Date()) {
|
||||
const next = nextBirthdayDate(birthDate, from);
|
||||
return `${next}T12:00:00Z`;
|
||||
}
|
||||
|
||||
function eventTitle(name) {
|
||||
return `Birthday: ${name}`;
|
||||
}
|
||||
|
||||
function eventDescription(name, birthDate) {
|
||||
return `Birthday reminder for ${name} (${birthDate}).`;
|
||||
}
|
||||
|
||||
function syncBirthdayCalendarEvent(database, birthday) {
|
||||
const payload = {
|
||||
title: eventTitle(birthday.name),
|
||||
description: eventDescription(birthday.name, birthday.birth_date),
|
||||
start_datetime: birthday.birth_date,
|
||||
end_datetime: null,
|
||||
all_day: 1,
|
||||
location: null,
|
||||
color: BIRTHDAY_COLOR,
|
||||
assigned_to: null,
|
||||
recurrence_rule: BIRTHDAY_RRULE,
|
||||
created_by: birthday.created_by,
|
||||
};
|
||||
|
||||
if (birthday.calendar_event_id) {
|
||||
const existing = database.prepare('SELECT id FROM calendar_events WHERE id = ?').get(birthday.calendar_event_id);
|
||||
if (existing) {
|
||||
database.prepare(`
|
||||
UPDATE calendar_events
|
||||
SET title = ?, description = ?, start_datetime = ?, end_datetime = ?, all_day = ?,
|
||||
location = ?, color = ?, assigned_to = ?, recurrence_rule = ?, created_by = ?,
|
||||
external_source = 'local'
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
payload.title,
|
||||
payload.description,
|
||||
payload.start_datetime,
|
||||
payload.end_datetime,
|
||||
payload.all_day,
|
||||
payload.location,
|
||||
payload.color,
|
||||
payload.assigned_to,
|
||||
payload.recurrence_rule,
|
||||
payload.created_by,
|
||||
birthday.calendar_event_id,
|
||||
);
|
||||
return birthday.calendar_event_id;
|
||||
}
|
||||
}
|
||||
|
||||
const result = database.prepare(`
|
||||
INSERT INTO calendar_events
|
||||
(title, description, start_datetime, end_datetime, all_day, location, color,
|
||||
assigned_to, created_by, recurrence_rule, external_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'local')
|
||||
`).run(
|
||||
payload.title,
|
||||
payload.description,
|
||||
payload.start_datetime,
|
||||
payload.end_datetime,
|
||||
payload.all_day,
|
||||
payload.location,
|
||||
payload.color,
|
||||
payload.assigned_to,
|
||||
payload.created_by,
|
||||
payload.recurrence_rule,
|
||||
);
|
||||
|
||||
database.prepare('UPDATE birthdays SET calendar_event_id = ? WHERE id = ?')
|
||||
.run(result.lastInsertRowid, birthday.id);
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
function syncBirthdayReminder(database, birthday, from = new Date()) {
|
||||
if (!birthday.calendar_event_id) return null;
|
||||
|
||||
const desired = birthdayReminderAt(birthday.birth_date, from);
|
||||
const existing = database.prepare(`
|
||||
SELECT * FROM reminders
|
||||
WHERE entity_type = 'event' AND entity_id = ? AND created_by = ?
|
||||
ORDER BY created_at DESC
|
||||
`).all(birthday.calendar_event_id, birthday.created_by);
|
||||
|
||||
const active = existing.find((row) => row.dismissed === 0);
|
||||
if (active && active.remind_at === desired) return active.id;
|
||||
|
||||
database.prepare(`
|
||||
DELETE FROM reminders
|
||||
WHERE entity_type = 'event' AND entity_id = ? AND created_by = ?
|
||||
`).run(birthday.calendar_event_id, birthday.created_by);
|
||||
|
||||
const result = database.prepare(`
|
||||
INSERT INTO reminders (entity_type, entity_id, remind_at, created_by)
|
||||
VALUES ('event', ?, ?, ?)
|
||||
`).run(birthday.calendar_event_id, desired, birthday.created_by);
|
||||
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
function syncBirthdayArtifacts(database, birthday, from = new Date()) {
|
||||
const calendarEventId = syncBirthdayCalendarEvent(database, birthday);
|
||||
const refreshed = { ...birthday, calendar_event_id: calendarEventId };
|
||||
syncBirthdayReminder(database, refreshed, from);
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
function deleteBirthdayArtifacts(database, birthday) {
|
||||
if (birthday.calendar_event_id) {
|
||||
database.prepare(`
|
||||
DELETE FROM reminders
|
||||
WHERE entity_type = 'event' AND entity_id = ? AND created_by = ?
|
||||
`).run(birthday.calendar_event_id, birthday.created_by);
|
||||
database.prepare('DELETE FROM calendar_events WHERE id = ?').run(birthday.calendar_event_id);
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateBirthday(row, from = new Date()) {
|
||||
const next_birthday = nextBirthdayDate(row.birth_date, from);
|
||||
return {
|
||||
...row,
|
||||
next_birthday,
|
||||
next_age: nextBirthdayAge(row.birth_date, from),
|
||||
days_until: daysUntilBirthday(row.birth_date, from),
|
||||
};
|
||||
}
|
||||
|
||||
function syncAllBirthdayReminders(database, userId, from = new Date()) {
|
||||
const birthdays = database.prepare(`
|
||||
SELECT * FROM birthdays WHERE created_by = ? ORDER BY birth_date ASC
|
||||
`).all(userId);
|
||||
birthdays.forEach((birthday) => {
|
||||
const refreshed = birthday.calendar_event_id ? birthday : {
|
||||
...birthday,
|
||||
calendar_event_id: syncBirthdayCalendarEvent(database, birthday),
|
||||
};
|
||||
syncBirthdayReminder(database, refreshed, from);
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
BIRTHDAY_COLOR,
|
||||
BIRTHDAY_RRULE,
|
||||
birthdayReminderAt,
|
||||
daysUntilBirthday,
|
||||
deleteBirthdayArtifacts,
|
||||
eventDescription,
|
||||
eventTitle,
|
||||
hydrateBirthday,
|
||||
nextBirthdayAge,
|
||||
nextBirthdayDate,
|
||||
syncAllBirthdayReminders,
|
||||
syncBirthdayArtifacts,
|
||||
};
|
||||
Reference in New Issue
Block a user