c0a79b9ed0
- Admin can set birthday (month/day) per student in Study Users panel - Summer birthdays (Jun–Aug) prompt admin to set an alternate school-year date - 🎂 badge on student card header during birthday week - Dismissible happy birthday banner shown to student on their study hub - Admin receives email notification on the celebration day, once per year Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
170 lines
7.0 KiB
JavaScript
170 lines
7.0 KiB
JavaScript
import { Resend } from 'resend'
|
|
import { state } from './state.js'
|
|
import { queuePodcastChecklistWrite, queueCalendarEventsWrite, queueStudyUsersWrite } from './data.js'
|
|
import { DEFAULT_RESEND_FROM, DEFAULT_RESEND_TO } from './config.js'
|
|
import { celebrationDateForYear } from './helpers.js'
|
|
|
|
function toDateKey(date) {
|
|
return date.toISOString().slice(0, 10)
|
|
}
|
|
|
|
export function startReminderScheduler() {
|
|
checkReminders()
|
|
setInterval(checkReminders, 60 * 60 * 1000)
|
|
}
|
|
|
|
async function checkReminders() {
|
|
const todayKey = toDateKey(new Date())
|
|
|
|
// Episodes
|
|
const episodes = state.podcastChecklist?.episodes
|
|
if (Array.isArray(episodes)) {
|
|
let changed = false
|
|
for (const ep of episodes) {
|
|
if (!ep.datePublished || !(ep.reminderDays > 0) || ep.reminderSentAt) continue
|
|
const publish = new Date(ep.datePublished + 'T12:00:00Z')
|
|
if (isNaN(publish.getTime())) continue
|
|
const reminderDate = new Date(publish)
|
|
reminderDate.setUTCDate(reminderDate.getUTCDate() - ep.reminderDays)
|
|
if (todayKey === toDateKey(reminderDate)) {
|
|
const sent = await sendReminderEmail({
|
|
label: ep.episodeNumber ? `Episode ${ep.episodeNumber}` : 'Episode',
|
|
title: ep.title || 'Untitled',
|
|
series: ep.series,
|
|
date: ep.datePublished,
|
|
reminderDays: ep.reminderDays,
|
|
type: 'episode',
|
|
})
|
|
if (sent) { ep.reminderSentAt = new Date().toISOString(); changed = true }
|
|
}
|
|
}
|
|
if (changed) queuePodcastChecklistWrite()
|
|
}
|
|
|
|
// Calendar events
|
|
const events = state.calendarEvents
|
|
if (Array.isArray(events)) {
|
|
let changed = false
|
|
for (const ev of events) {
|
|
if (!ev.date || !(ev.reminderDays > 0) || ev.reminderSentAt) continue
|
|
const publish = new Date(ev.date + 'T12:00:00Z')
|
|
if (isNaN(publish.getTime())) continue
|
|
const reminderDate = new Date(publish)
|
|
reminderDate.setUTCDate(reminderDate.getUTCDate() - ev.reminderDays)
|
|
if (todayKey === toDateKey(reminderDate)) {
|
|
const sent = await sendReminderEmail({
|
|
label: ev.type.charAt(0).toUpperCase() + ev.type.slice(1),
|
|
title: ev.title,
|
|
series: null,
|
|
date: ev.date,
|
|
reminderDays: ev.reminderDays,
|
|
type: ev.type,
|
|
})
|
|
if (sent) { ev.reminderSentAt = new Date().toISOString(); changed = true }
|
|
}
|
|
}
|
|
if (changed) queueCalendarEventsWrite()
|
|
}
|
|
|
|
// Birthdays — fire on the student's celebration day, once per calendar year
|
|
const users = state.studyUsers ?? []
|
|
let birthdayUsersChanged = false
|
|
const todayYear = new Date().getUTCFullYear()
|
|
const birthdayKids = []
|
|
|
|
for (const user of users) {
|
|
if (!user.birthdayMonth || !user.birthdayDay) continue
|
|
const celebDate = celebrationDateForYear(user, todayYear)
|
|
if (!celebDate) continue
|
|
if (todayKey !== celebDate) continue
|
|
if (user.birthdayEmailSentYear === todayYear) continue
|
|
birthdayKids.push({ name: user.displayName || user.username, birthdayMonth: user.birthdayMonth, birthdayDay: user.birthdayDay, hasAlternate: Boolean(user.birthdayAlternateMonth) })
|
|
user.birthdayEmailSentYear = todayYear
|
|
birthdayUsersChanged = true
|
|
}
|
|
|
|
if (birthdayKids.length > 0) {
|
|
await sendBirthdayNotificationEmail(birthdayKids)
|
|
if (birthdayUsersChanged) queueStudyUsersWrite()
|
|
}
|
|
}
|
|
|
|
async function sendBirthdayNotificationEmail(kids) {
|
|
if (!process.env.RESEND_API_KEY) return
|
|
try {
|
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
|
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
|
|
const to = process.env.RESEND_TO ?? DEFAULT_RESEND_TO
|
|
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']
|
|
|
|
const lines = kids.map(k => {
|
|
const bdayStr = `${MONTHS[k.birthdayMonth - 1]} ${k.birthdayDay}`
|
|
const alt = k.hasAlternate ? ' (celebrating on alternate date)' : ''
|
|
return `<li style="margin-bottom:6px"><strong>${k.name}</strong> — birthday: ${bdayStr}${alt}</li>`
|
|
}).join('')
|
|
|
|
const subject = kids.length === 1
|
|
? `🎂 Birthday today: ${kids[0].name}`
|
|
: `🎂 ${kids.length} student birthdays today`
|
|
|
|
const html = `
|
|
<div style="font-family:system-ui,sans-serif;max-width:540px;margin:0 auto;color:#222">
|
|
<h2 style="color:#c8860a;margin-bottom:4px">🎂 Student Birthday Celebration</h2>
|
|
<p>The following student${kids.length > 1 ? 's have' : ' has'} a birthday celebration today — consider sending a special message or acknowledging them in your study community!</p>
|
|
<ul style="padding-left:1.2em">${lines}</ul>
|
|
<hr style="border:none;border-top:1px solid #eee;margin:16px 0">
|
|
<p style="color:#777;font-size:0.85rem">To edit birthday info, open the Study Users panel in your admin settings.</p>
|
|
</div>
|
|
`
|
|
const { error } = await resend.emails.send({ from, to, subject, html })
|
|
if (error) console.error('[birthday] send error:', error)
|
|
else console.log(`[birthday] notification sent for ${kids.map(k => k.name).join(', ')}`)
|
|
} catch (err) {
|
|
console.error('[birthday] send exception:', err)
|
|
}
|
|
}
|
|
|
|
const TYPE_ICONS = { episode: '📅', general: '📌', recording: '🎙️', social: '📱', task: '✅' }
|
|
|
|
async function sendReminderEmail({ label, title, series, date, reminderDays, type }) {
|
|
if (!process.env.RESEND_API_KEY) return false
|
|
|
|
try {
|
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
|
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
|
|
const to = process.env.RESEND_TO ?? DEFAULT_RESEND_TO
|
|
|
|
const eventDate = new Date(date + 'T12:00:00Z')
|
|
const msLeft = eventDate.getTime() - Date.now()
|
|
const daysLeft = Math.max(0, Math.ceil(msLeft / (1000 * 60 * 60 * 24)))
|
|
|
|
const seriesStr = series ? ` (${series})` : ''
|
|
const daysText = daysLeft === 0 ? 'today' : daysLeft === 1 ? 'in 1 day' : `in ${daysLeft} days`
|
|
const icon = TYPE_ICONS[type] ?? '📅'
|
|
const subject = `Reminder: ${label}: ${title} — ${daysText}`
|
|
|
|
const html = `
|
|
<div style="font-family:system-ui,sans-serif;max-width:540px;margin:0 auto;color:#222">
|
|
<h2 style="color:#c8860a;margin-bottom:4px">${icon} Calendar Reminder</h2>
|
|
<p style="font-size:1.1rem;margin-bottom:16px">
|
|
<strong>${label}: ${title}</strong>${seriesStr}<br>
|
|
<span style="color:#555">Scheduled for <strong>${date}</strong> — ${daysText}</span>
|
|
</p>
|
|
<hr style="border:none;border-top:1px solid #eee;margin:16px 0">
|
|
<p style="color:#777;font-size:0.85rem">
|
|
This reminder was set ${reminderDays} day${reminderDays === 1 ? '' : 's'} before the date.
|
|
To change or remove it, open the Calendar in your admin panel.
|
|
</p>
|
|
</div>
|
|
`
|
|
|
|
const { error } = await resend.emails.send({ from, to, subject, html })
|
|
if (error) { console.error('[reminder] send error:', error); return false }
|
|
console.log(`[reminder] sent for "${title}" (${date})`)
|
|
return true
|
|
} catch (err) {
|
|
console.error('[reminder] send exception:', err)
|
|
return false
|
|
}
|
|
}
|