Adding Birthday tracking feature - to compete with FamilyWall

This commit is contained in:
Rafael Foster
2026-04-26 07:36:53 -03:00
parent a1b1a71227
commit 394b4ea84e
30 changed files with 1518 additions and 14 deletions
+201
View File
@@ -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,
};