Add student birthday tracking with celebration banners and admin notification; v1.1.31

- 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>
This commit is contained in:
nmemmert
2026-08-10 08:42:52 -04:00
parent 6a4399f7a6
commit c0a79b9ed0
7 changed files with 251 additions and 5 deletions
+94
View File
@@ -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<Record<string, string>>({})
const [msg, setMsg] = useState<Record<string, string>>({})
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
const [editBirthday, setEditBirthday] = useState<Record<string, { month: string; day: string; altMonth: string; altDay: string }>>({})
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 112.'); return }
if (bDay !== null && (bDay < 1 || bDay > 31)) { flashMsg(user.id, 'Day must be 131.'); 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) : '',
},
}))
}
}}
>
<div className="admin-study-user-header-left">
@@ -275,6 +314,7 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
<span className="admin-study-user-pill">{user.enrolledStudies.length} enrolled</span>
<span className="admin-study-user-pill">{user.noteCount} notes</span>
{(user.currentStreak ?? 0) > 0 && <span className="admin-study-user-pill">{user.currentStreak} 🔥</span>}
{user.isBirthdayWeek && <span className="admin-study-user-pill" title="Birthday this week!" style={{ background: 'rgba(255,200,0,0.18)', color: '#b38600' }}>🎂 Birthday Week!</span>}
<span style={{ color: '#5a5440', fontSize: '0.8rem' }}>{isExpanded ? '▲' : '▼'}</span>
</div>
</button>
@@ -305,6 +345,60 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
</div>
</div>
{/* 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 (
<div className="admin-study-user-section">
<h4>Birthday</h4>
<p className="admin-stats-note" style={{ marginBottom: '0.5rem' }}>Admin-only. Used for birthday celebration notifications and the student's birthday week banner.</p>
<div className="admin-study-user-row" style={{ flexWrap: 'wrap', gap: '0.4rem' }}>
<select value={b.month} onChange={e => bdayUpdate('month', e.target.value)} style={{ width: '130px' }}>
<option value="">Month</option>
{MONTHS.map((name, i) => <option key={i + 1} value={String(i + 1)}>{name}</option>)}
</select>
<select value={b.day} onChange={e => bdayUpdate('day', e.target.value)} style={{ width: '80px' }}>
<option value="">Day</option>
{Array.from({ length: 31 }, (_, i) => <option key={i + 1} value={String(i + 1)}>{i + 1}</option>)}
</select>
<button type="button" className="btn-admin-save" onClick={() => handleSaveBirthday(user)}>Save</button>
{(b.month && b.day) && (
<button type="button" className="btn-admin-reset" style={{ fontSize: '0.78rem' }} onClick={() => {
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</button>
)}
</div>
{isSummer && (
<div style={{ marginTop: '0.65rem', padding: '0.55rem 0.7rem', background: 'rgba(255,180,0,0.1)', border: '1px solid rgba(255,180,0,0.3)', borderRadius: '5px' }}>
<p style={{ margin: '0 0 0.4rem', fontSize: '0.83rem', color: '#8a6200' }}><strong> Summer birthday</strong> this falls during summer break (JuneAugust). Set an alternate school-year celebration date below so they still get recognized during the school year.</p>
<div className="admin-study-user-row" style={{ flexWrap: 'wrap', gap: '0.4rem', marginTop: '0.35rem' }}>
<select value={b.altMonth} onChange={e => bdayUpdate('altMonth', e.target.value)} style={{ width: '130px' }}>
<option value="">Celebrate in</option>
{MONTHS.map((name, i) => <option key={i + 1} value={String(i + 1)}>{name}</option>)}
</select>
<select value={b.altDay} onChange={e => bdayUpdate('altDay', e.target.value)} style={{ width: '80px' }}>
<option value="">Day</option>
{Array.from({ length: 31 }, (_, i) => <option key={i + 1} value={String(i + 1)}>{i + 1}</option>)}
</select>
</div>
{(b.altMonth && b.altDay) && <p className="admin-stats-note" style={{ marginTop: '0.3rem' }}>Birthday banner and email will fire on {MONTHS[parseInt(b.altMonth, 10) - 1]} {b.altDay} instead of their summer birthday.</p>}
</div>
)}
{user.isBirthdayWeek && (
<p style={{ marginTop: '0.5rem', fontSize: '0.85rem', color: '#b38600' }}>🎂 This student's celebration week is <strong>right now</strong>!</p>
)}
</div>
)
})()}
{/* Password Reset */}
<div className="admin-study-user-section">
<h4>Reset Password</h4>