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>
|
||||
|
||||
@@ -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<StudyProgress>({ 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 (
|
||||
<main className="study-index-page" aria-label={`${study.title} study`}>
|
||||
{auth.authenticated && auth.isBirthdayWeek && !birthdayBannerDismissed && (
|
||||
<div role="alert" style={{ background: 'linear-gradient(135deg, #fffbe6 0%, #fff3cc 100%)', borderBottom: '2px solid #f0c930', padding: '0.9rem 1.25rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: '1.05rem', color: '#7a5800' }}>
|
||||
🎂 <strong>Happy Birthday{auth.displayName ? `, ${auth.displayName}` : ''}!</strong> Wishing you a wonderful birthday week — we're so glad you're part of this study community. 🎉
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { 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' }}
|
||||
>✕</button>
|
||||
</div>
|
||||
)}
|
||||
<section className="section-study-course-hero">
|
||||
<div className="section-inner study-course-hero-inner">
|
||||
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title }]} />
|
||||
|
||||
Reference in New Issue
Block a user