diff --git a/package.json b/package.json
index 30b4ac0..f82d549 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
- "version": "1.1.30",
+ "version": "1.1.31",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/server/helpers.js b/server/helpers.js
index 434ea2a..2042dcf 100644
--- a/server/helpers.js
+++ b/server/helpers.js
@@ -121,6 +121,51 @@ export function sanitizeRedirectRules(value) {
return out.length > 0 ? out : DEFAULT_REDIRECT_RULES
}
+// Returns true if the user's celebration date falls within the current MonβSun week.
+// Uses birthdayAlternateMonth/Day if set (for summer birthday workarounds), otherwise
+// falls back to birthdayMonth/Day. Checks both the current year and next year so
+// year-wrap birthdays (e.g. Dec 30 checked in late December) work correctly.
+export function isBirthdayThisWeek(user) {
+ const month = user.birthdayMonth
+ const day = user.birthdayDay
+ if (!month || !day) return false
+
+ const celebMonth = user.birthdayAlternateMonth ?? month
+ const celebDay = user.birthdayAlternateDay ?? day
+
+ const today = new Date()
+ const monday = new Date(today)
+ monday.setUTCHours(0, 0, 0, 0)
+ const dow = monday.getUTCDay()
+ monday.setUTCDate(monday.getUTCDate() - (dow === 0 ? 6 : dow - 1))
+ const sunday = new Date(monday)
+ sunday.setUTCDate(sunday.getUTCDate() + 6)
+ sunday.setUTCHours(23, 59, 59, 999)
+
+ const yr = today.getUTCFullYear()
+ for (const year of [yr, yr + 1]) {
+ const bday = new Date(Date.UTC(year, celebMonth - 1, celebDay))
+ if (bday >= monday && bday <= sunday) return true
+ }
+ return false
+}
+
+// June, July, August are considered summer months.
+export function isSummerBirthday(month) {
+ return Number.isInteger(month) && month >= 6 && month <= 8
+}
+
+// Returns "YYYY-MM-DD" for the celebration date (alternate if set, else birthday)
+// in the given year. Returns null if no birthday is set.
+export function celebrationDateForYear(user, year) {
+ const month = user.birthdayMonth
+ const day = user.birthdayDay
+ if (!month || !day) return null
+ const m = user.birthdayAlternateMonth ?? month
+ const d = user.birthdayAlternateDay ?? day
+ return `${year}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
+}
+
export function sanitizeFeaturedLinks(value) {
const source = Array.isArray(value) ? value : []
return source
diff --git a/server/reminder.js b/server/reminder.js
index 373b8a4..cab55a9 100644
--- a/server/reminder.js
+++ b/server/reminder.js
@@ -1,7 +1,8 @@
import { Resend } from 'resend'
import { state } from './state.js'
-import { queuePodcastChecklistWrite, queueCalendarEventsWrite } from './data.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)
@@ -64,6 +65,63 @@ async function checkReminders() {
}
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 `
${k.name} β birthday: ${bdayStr}${alt} `
+ }).join('')
+
+ const subject = kids.length === 1
+ ? `π Birthday today: ${kids[0].name}`
+ : `π ${kids.length} student birthdays today`
+
+ const html = `
+
+
π Student Birthday Celebration
+
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!
+
+
+
To edit birthday info, open the Study Users panel in your admin settings.
+
+ `
+ 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: 'β
' }
diff --git a/server/routes/admin-assets.js b/server/routes/admin-assets.js
index 0a48bca..d60e566 100644
--- a/server/routes/admin-assets.js
+++ b/server/routes/admin-assets.js
@@ -1,6 +1,6 @@
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
-import { inferImageExtensionFromDataUrl, normalizeAssetBaseName } from '../helpers.js'
+import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, isBirthdayThisWeek } from '../helpers.js'
import { requireAdminAuth } from '../auth.js'
import { UPLOADS_DIR } from '../config.js'
import { state } from '../state.js'
@@ -132,6 +132,11 @@ export function register(app) {
noteCount,
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
+ birthdayMonth: user.birthdayMonth ?? null,
+ birthdayDay: user.birthdayDay ?? null,
+ birthdayAlternateMonth: user.birthdayAlternateMonth ?? null,
+ birthdayAlternateDay: user.birthdayAlternateDay ?? null,
+ isBirthdayWeek: isBirthdayThisWeek(user),
}
}))
@@ -142,7 +147,7 @@ export function register(app) {
const user = state.studyUsers.find(u => u.id === req.params.id)
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
- const { displayName, newPassword, addEnrollment, removeEnrollment } = req.body ?? {}
+ const { displayName, newPassword, addEnrollment, removeEnrollment, birthdayMonth, birthdayDay, birthdayAlternateMonth, birthdayAlternateDay } = req.body ?? {}
if (typeof displayName === 'string') {
user.displayName = displayName.trim().slice(0, 80)
@@ -172,6 +177,28 @@ export function register(app) {
user.enrolledStudySlugs = (user.enrolledStudySlugs ?? []).filter(s => s !== slug)
}
+ if (birthdayMonth !== undefined) {
+ const m = parseInt(birthdayMonth, 10)
+ user.birthdayMonth = (Number.isInteger(m) && m >= 1 && m <= 12) ? m : null
+ }
+ if (birthdayDay !== undefined) {
+ const d = parseInt(birthdayDay, 10)
+ user.birthdayDay = (Number.isInteger(d) && d >= 1 && d <= 31) ? d : null
+ }
+ if (birthdayAlternateMonth !== undefined) {
+ const m = parseInt(birthdayAlternateMonth, 10)
+ user.birthdayAlternateMonth = (Number.isInteger(m) && m >= 1 && m <= 12) ? m : null
+ if (!user.birthdayAlternateMonth) user.birthdayAlternateDay = null
+ }
+ if (birthdayAlternateDay !== undefined) {
+ const d = parseInt(birthdayAlternateDay, 10)
+ user.birthdayAlternateDay = (Number.isInteger(d) && d >= 1 && d <= 31) ? d : null
+ }
+ // Clear the sent-year so a changed birthday can trigger a new notification
+ if (birthdayMonth !== undefined || birthdayDay !== undefined || birthdayAlternateMonth !== undefined || birthdayAlternateDay !== undefined) {
+ user.birthdayEmailSentYear = null
+ }
+
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs })
diff --git a/server/routes/study-auth.js b/server/routes/study-auth.js
index 2c2a12b..fb0b3fa 100644
--- a/server/routes/study-auth.js
+++ b/server/routes/study-auth.js
@@ -6,7 +6,7 @@ import {
verifyTotpCode,
generateRecoveryCodes,
} from '../auth.js'
-import { parseCookies } from '../helpers.js'
+import { parseCookies, isBirthdayThisWeek } from '../helpers.js'
import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS } from '../config.js'
import { state } from '../state.js'
import {
@@ -57,6 +57,7 @@ export function register(app) {
totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))),
twoFaMethod: user?.twoFaMethod ?? null,
totpRecoveryCodesRemaining: user?.twoFaMethod === 'app' ? (user.totpRecoveryCodes?.length ?? 0) : 0,
+ isBirthdayWeek: user ? isBirthdayThisWeek(user) : false,
})
})
diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx
index c3cb049..9312457 100644
--- a/src/AdminPage.tsx
+++ b/src/AdminPage.tsx
@@ -143,6 +143,11 @@ interface StudyUserRecord {
noteCount: number
subscribeNewsletter: boolean
studyRemindersEnabled: boolean
+ birthdayMonth: number | null
+ birthdayDay: number | null
+ birthdayAlternateMonth: number | null
+ birthdayAlternateDay: number | null
+ isBirthdayWeek: boolean
}
function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
@@ -154,6 +159,8 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
const [newPassword, setNewPassword] = useState>({})
const [msg, setMsg] = useState>({})
const [confirmDeleteId, setConfirmDeleteId] = useState(null)
+ const [editBirthday, setEditBirthday] = useState>({})
+
function flashMsg(id: string, text: string) {
setMsg(prev => ({ ...prev, [id]: text }))
@@ -211,6 +218,27 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
}
+ async function handleSaveBirthday(user: StudyUserRecord) {
+ const b = editBirthday[user.id] ?? { month: String(user.birthdayMonth ?? ''), day: String(user.birthdayDay ?? ''), altMonth: String(user.birthdayAlternateMonth ?? ''), altDay: String(user.birthdayAlternateDay ?? '') }
+ const bMonth = b.month ? parseInt(b.month, 10) : null
+ const bDay = b.day ? parseInt(b.day, 10) : null
+ const altMonth = b.altMonth ? parseInt(b.altMonth, 10) : null
+ const altDay = b.altDay ? parseInt(b.altDay, 10) : null
+ if (bMonth !== null && (bMonth < 1 || bMonth > 12)) { flashMsg(user.id, 'Month must be 1β12.'); return }
+ if (bDay !== null && (bDay < 1 || bDay > 31)) { flashMsg(user.id, 'Day must be 1β31.'); return }
+ if ((altMonth == null) !== (altDay == null)) { flashMsg(user.id, 'Set both alternate month and day, or neither.'); return }
+ try {
+ await patchUser(user.id, {
+ birthdayMonth: bMonth !== null ? String(bMonth) : '',
+ birthdayDay: bDay !== null ? String(bDay) : '',
+ birthdayAlternateMonth: altMonth !== null ? String(altMonth) : '',
+ birthdayAlternateDay: altDay !== null ? String(altDay) : '',
+ })
+ setUsers(prev => prev.map(u => u.id !== user.id ? u : { ...u, birthdayMonth: bMonth, birthdayDay: bDay, birthdayAlternateMonth: altMonth, birthdayAlternateDay: altDay }))
+ flashMsg(user.id, 'Birthday saved.')
+ } catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
+ }
+
async function handleUnenroll(user: StudyUserRecord, slug: string) {
try {
await patchUser(user.id, { removeEnrollment: slug })
@@ -265,6 +293,17 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
onClick={() => {
setExpandedId(isExpanded ? null : user.id)
setEditDisplayName(prev => ({ ...prev, [user.id]: user.displayName }))
+ if (!isExpanded) {
+ setEditBirthday(prev => ({
+ ...prev,
+ [user.id]: {
+ month: user.birthdayMonth ? String(user.birthdayMonth) : '',
+ day: user.birthdayDay ? String(user.birthdayDay) : '',
+ altMonth: user.birthdayAlternateMonth ? String(user.birthdayAlternateMonth) : '',
+ altDay: user.birthdayAlternateDay ? String(user.birthdayAlternateDay) : '',
+ },
+ }))
+ }
}}
>
@@ -275,6 +314,7 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
{user.enrolledStudies.length} enrolled
{user.noteCount} notes
{(user.currentStreak ?? 0) > 0 && {user.currentStreak} π₯ }
+ {user.isBirthdayWeek && π Birthday Week! }
{isExpanded ? 'β²' : 'βΌ'}
@@ -305,6 +345,60 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
+ {/* Birthday */}
+ {(() => {
+ const b = editBirthday[user.id] ?? { month: String(user.birthdayMonth ?? ''), day: String(user.birthdayDay ?? ''), altMonth: String(user.birthdayAlternateMonth ?? ''), altDay: String(user.birthdayAlternateDay ?? '') }
+ const isSummer = (() => { const m = parseInt(b.month, 10); return Number.isInteger(m) && m >= 6 && m <= 8 })()
+ const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
+ function bdayUpdate(field: string, val: string) {
+ setEditBirthday(prev => ({ ...prev, [user.id]: { ...(prev[user.id] ?? { month: '', day: '', altMonth: '', altDay: '' }), [field]: val } }))
+ }
+ return (
+
+
Birthday
+
Admin-only. Used for birthday celebration notifications and the student's birthday week banner.
+
+ bdayUpdate('month', e.target.value)} style={{ width: '130px' }}>
+ Monthβ¦
+ {MONTHS.map((name, i) => {name} )}
+
+ bdayUpdate('day', e.target.value)} style={{ width: '80px' }}>
+ Dayβ¦
+ {Array.from({ length: 31 }, (_, i) => {i + 1} )}
+
+ handleSaveBirthday(user)}>Save
+ {(b.month && b.day) && (
+ {
+ setEditBirthday(prev => ({ ...prev, [user.id]: { month: '', day: '', altMonth: '', altDay: '' } }))
+ patchUser(user.id, { birthdayMonth: '', birthdayDay: '', birthdayAlternateMonth: '', birthdayAlternateDay: '' })
+ .then(() => { setUsers(prev => prev.map(u => u.id !== user.id ? u : { ...u, birthdayMonth: null, birthdayDay: null, birthdayAlternateMonth: null, birthdayAlternateDay: null })); flashMsg(user.id, 'Birthday cleared.') })
+ .catch(() => flashMsg(user.id, 'Error clearing birthday.'))
+ }}>Clear
+ )}
+
+ {isSummer && (
+
+
βοΈ Summer birthday β this falls during summer break (JuneβAugust). Set an alternate school-year celebration date below so they still get recognized during the school year.
+
+ bdayUpdate('altMonth', e.target.value)} style={{ width: '130px' }}>
+ Celebrate inβ¦
+ {MONTHS.map((name, i) => {name} )}
+
+ bdayUpdate('altDay', e.target.value)} style={{ width: '80px' }}>
+ Dayβ¦
+ {Array.from({ length: 31 }, (_, i) => {i + 1} )}
+
+
+ {(b.altMonth && b.altDay) &&
Birthday banner and email will fire on {MONTHS[parseInt(b.altMonth, 10) - 1]} {b.altDay} instead of their summer birthday.
}
+
+ )}
+ {user.isBirthdayWeek && (
+
π This student's celebration week is right now !
+ )}
+
+ )
+ })()}
+
{/* Password Reset */}
Reset Password
diff --git a/src/colossiansStudy.tsx b/src/colossiansStudy.tsx
index c9dca17..6fe5f15 100644
--- a/src/colossiansStudy.tsx
+++ b/src/colossiansStudy.tsx
@@ -23,6 +23,7 @@ type StudyAuthState = {
totpEnabled?: boolean
twoFaMethod?: 'app' | 'email' | null
totpRecoveryCodesRemaining?: number
+ isBirthdayWeek?: boolean
}
type StudyAuthStatusResponse = {
@@ -36,6 +37,7 @@ type StudyAuthStatusResponse = {
totpEnabled?: boolean
twoFaMethod?: 'app' | 'email' | null
totpRecoveryCodesRemaining?: number
+ isBirthdayWeek?: boolean
}
type StudyAccountOverview = {
@@ -1258,6 +1260,10 @@ export function ColossiansStudyIndexPage({ content }: Props) {
const [enrollMessage, setEnrollMessage] = useState('')
const [studyProgress, setStudyProgress] = useState
({ completedSectionIds: [] })
const [progressLoading, setProgressLoading] = useState(false)
+ const [birthdayBannerDismissed, setBirthdayBannerDismissed] = useState(() => {
+ const yr = new Date().getFullYear()
+ return localStorage.getItem(`birthday-banner-dismissed-${yr}`) === '1'
+ })
useEffect(() => {
let cancelled = false
@@ -1268,7 +1274,9 @@ export function ColossiansStudyIndexPage({ content }: Props) {
checked: true,
authenticated: Boolean(data.authenticated),
username: data.username ?? '',
+ displayName: data.displayName ?? '',
enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs),
+ isBirthdayWeek: data.isBirthdayWeek === true,
})
})
.catch(() => {
@@ -1351,6 +1359,19 @@ export function ColossiansStudyIndexPage({ content }: Props) {
return (
+ {auth.authenticated && auth.isBirthdayWeek && !birthdayBannerDismissed && (
+
+
+ π Happy Birthday{auth.displayName ? `, ${auth.displayName}` : ''}! Wishing you a wonderful birthday week β we're so glad you're part of this study community. π
+
+ { setBirthdayBannerDismissed(true); localStorage.setItem(`birthday-banner-dismissed-${new Date().getFullYear()}`, '1') }}
+ aria-label="Dismiss birthday banner"
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#7a5800', fontSize: '1.1rem', lineHeight: 1, padding: '0.2rem 0.4rem', borderRadius: '3px' }}
+ >β
+
+ )}