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:
@@ -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 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) : '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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 (June–August). 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>
|
||||
|
||||
Reference in New Issue
Block a user