Add birthday celebrations for students
Parents can set a birthday (MM-DD) per student in the Kids panel. During the student's birthday week (Mon–Sun), a festive overlay appears on login with balloons, birthday cake, confetti, and a personalized message — shown once per day via sessionStorage. Summer birthdays (June–Aug) show a note in admin suggesting the parent set an alternate school-week date to celebrate early. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
7f6268d6e5
commit
1eedfc7715
7 files changed
+146
-8
No files matched your search
@@ -230,6 +230,7 @@ tryAlter(`ALTER TABLE students ADD COLUMN streak_count INTEGER NOT NULL DEFAULT
|
|||||||
tryAlter(`ALTER TABLE students ADD COLUMN best_streak INTEGER NOT NULL DEFAULT 0`);
|
tryAlter(`ALTER TABLE students ADD COLUMN best_streak INTEGER NOT NULL DEFAULT 0`);
|
||||||
tryAlter(`ALTER TABLE students ADD COLUMN bg_pattern TEXT NOT NULL DEFAULT 'none'`);
|
tryAlter(`ALTER TABLE students ADD COLUMN bg_pattern TEXT NOT NULL DEFAULT 'none'`);
|
||||||
tryAlter(`ALTER TABLE students ADD COLUMN young_learner INTEGER NOT NULL DEFAULT 0`);
|
tryAlter(`ALTER TABLE students ADD COLUMN young_learner INTEGER NOT NULL DEFAULT 0`);
|
||||||
|
tryAlter(`ALTER TABLE students ADD COLUMN birthday TEXT`);
|
||||||
tryAlter(`ALTER TABLE lists ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`);
|
tryAlter(`ALTER TABLE lists ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`);
|
||||||
tryAlter(`ALTER TABLE decks ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`);
|
tryAlter(`ALTER TABLE decks ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`);
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,9 @@
|
|||||||
<option>🐼</option><option>🦖</option><option>🐙</option><option>🦉</option>
|
<option>🐼</option><option>🦖</option><option>🐙</option><option>🦉</option>
|
||||||
<option>🐻</option><option>🐰</option><option>🦁</option><option>🐶</option>
|
<option>🐻</option><option>🐰</option><option>🦁</option><option>🐶</option>
|
||||||
</select>
|
</select>
|
||||||
|
<label style="font-size:.85rem;color:#666;margin-top:.25rem">🎂 Birthday (optional)
|
||||||
|
<input id="new-student-birthday" type="date" aria-label="Birthday" style="margin-top:.25rem">
|
||||||
|
</label>
|
||||||
<button type="submit">Add kid</button>
|
<button type="submit">Add kid</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+48
-2
@@ -118,6 +118,27 @@ function showPanel(name) {
|
|||||||
|
|
||||||
const THEME_COLORS = { blue: '#4f86f7', green: '#2e9e5b', purple: '#7c5cbf', orange: '#e8802a', pink: '#d45d8a', red: '#e84040', teal: '#20a8a0', yellow: '#c8960c', indigo: '#5b6abf' };
|
const THEME_COLORS = { blue: '#4f86f7', green: '#2e9e5b', purple: '#7c5cbf', orange: '#e8802a', pink: '#d45d8a', red: '#e84040', teal: '#20a8a0', yellow: '#c8960c', indigo: '#5b6abf' };
|
||||||
|
|
||||||
|
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
|
||||||
|
function formatBirthday(mmdd) {
|
||||||
|
if (!mmdd) return '';
|
||||||
|
const [mm, dd] = mmdd.split('-');
|
||||||
|
const month = MONTH_NAMES[Number(mm) - 1] || mm;
|
||||||
|
return `${month} ${Number(dd)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert stored MM-DD to YYYY-MM-DD for <input type="date"> (uses current year as placeholder)
|
||||||
|
function birthdayToDateInput(mmdd) {
|
||||||
|
if (!mmdd) return '';
|
||||||
|
return `${new Date().getFullYear()}-${mmdd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSummerBirthday(mmdd) {
|
||||||
|
if (!mmdd) return false;
|
||||||
|
const mm = Number(mmdd.split('-')[0]);
|
||||||
|
return mm >= 6 && mm <= 8;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadKids() {
|
async function loadKids() {
|
||||||
const students = await api('/api/students');
|
const students = await api('/api/students');
|
||||||
$('#student-rows').innerHTML = students.length
|
$('#student-rows').innerHTML = students.length
|
||||||
@@ -131,6 +152,11 @@ async function loadKids() {
|
|||||||
const youngLabel = s.young_learner
|
const youngLabel = s.young_learner
|
||||||
? `<span class="badge-young" title="Young learner mode on">👶 Young</span>`
|
? `<span class="badge-young" title="Young learner mode on">👶 Young</span>`
|
||||||
: '';
|
: '';
|
||||||
|
const bdayVal = birthdayToDateInput(s.birthday);
|
||||||
|
const bdayDisplay = s.birthday ? `🎂 ${formatBirthday(s.birthday)}` : '🎂 —';
|
||||||
|
const summerNote = isSummerBirthday(s.birthday)
|
||||||
|
? `<span style="font-size:.8rem;color:#c8760c" title="Summer birthday: the celebration shows that week. Change the date to a nearby school week to celebrate early instead.">☀️ Summer birthday</span>`
|
||||||
|
: '';
|
||||||
return `<div class="item-row" style="flex-wrap:wrap;gap:0.5rem">
|
return `<div class="item-row" style="flex-wrap:wrap;gap:0.5rem">
|
||||||
<span style="font-size:1.5rem">${esc(s.emoji)}</span>
|
<span style="font-size:1.5rem">${esc(s.emoji)}</span>
|
||||||
<strong class="grow">${esc(s.name)}</strong>
|
<strong class="grow">${esc(s.name)}</strong>
|
||||||
@@ -140,6 +166,13 @@ async function loadKids() {
|
|||||||
<label class="young-learner-toggle" title="Show word while typing (for younger kids)" style="font-size:0.85rem;display:flex;align-items:center;gap:0.3rem;cursor:pointer">
|
<label class="young-learner-toggle" title="Show word while typing (for younger kids)" style="font-size:0.85rem;display:flex;align-items:center;gap:0.3rem;cursor:pointer">
|
||||||
<input type="checkbox" data-young-learner="${s.id}" ${s.young_learner ? 'checked' : ''}> 👶 See it first
|
<input type="checkbox" data-young-learner="${s.id}" ${s.young_learner ? 'checked' : ''}> 👶 See it first
|
||||||
</label>
|
</label>
|
||||||
|
<div style="width:100%;display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;font-size:.85rem;padding-top:.25rem;border-top:1px solid #eee">
|
||||||
|
<span style="color:#555">${bdayDisplay}</span>
|
||||||
|
${summerNote}
|
||||||
|
<label style="display:flex;align-items:center;gap:.35rem;color:#555">
|
||||||
|
Edit: <input type="date" class="birthday-input" data-birthday-student="${s.id}" value="${bdayVal}" style="font-size:.82rem;border:1px solid #ccc;border-radius:4px;padding:2px 4px">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button class="danger" data-del-student="${s.id}">Remove</button>
|
<button class="danger" data-del-student="${s.id}">Remove</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
})
|
})
|
||||||
@@ -151,7 +184,6 @@ async function loadKids() {
|
|||||||
const studentId = btn.dataset.studentTheme;
|
const studentId = btn.dataset.studentTheme;
|
||||||
const theme = btn.dataset.theme;
|
const theme = btn.dataset.theme;
|
||||||
await api(`/api/students/${studentId}`, { method: 'PUT', body: { theme } });
|
await api(`/api/students/${studentId}`, { method: 'PUT', body: { theme } });
|
||||||
// Update selected state without full reload
|
|
||||||
document.querySelectorAll(`[data-student-theme="${studentId}"]`).forEach((b) =>
|
document.querySelectorAll(`[data-student-theme="${studentId}"]`).forEach((b) =>
|
||||||
b.classList.toggle('selected', b.dataset.theme === theme)
|
b.classList.toggle('selected', b.dataset.theme === theme)
|
||||||
);
|
);
|
||||||
@@ -167,6 +199,17 @@ async function loadKids() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Birthday edit: auto-save on change, strip year from YYYY-MM-DD → MM-DD
|
||||||
|
document.querySelectorAll('.birthday-input').forEach((input) =>
|
||||||
|
input.addEventListener('change', async () => {
|
||||||
|
const studentId = input.dataset.birthdayStudent;
|
||||||
|
const birthday = input.value ? input.value.slice(5) : '';
|
||||||
|
await api(`/api/students/${studentId}`, { method: 'PUT', body: { birthday } });
|
||||||
|
msg('Birthday saved.');
|
||||||
|
loadKids();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
document.querySelectorAll('[data-del-student]').forEach((btn) =>
|
document.querySelectorAll('[data-del-student]').forEach((btn) =>
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
if (!confirm('Remove this kid and all their progress? This cannot be undone.')) return;
|
if (!confirm('Remove this kid and all their progress? This cannot be undone.')) return;
|
||||||
@@ -181,8 +224,11 @@ $('#add-student-form').addEventListener('submit', async (e) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const name = $('#new-student-name').value.trim();
|
const name = $('#new-student-name').value.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
await api('/api/students', { method: 'POST', body: { name, emoji: $('#new-student-emoji').value } });
|
const bdayFull = $('#new-student-birthday').value; // YYYY-MM-DD from <input type="date">
|
||||||
|
const birthday = bdayFull ? bdayFull.slice(5) : ''; // strip year → MM-DD
|
||||||
|
await api('/api/students', { method: 'POST', body: { name, emoji: $('#new-student-emoji').value, birthday } });
|
||||||
$('#new-student-name').value = '';
|
$('#new-student-name').value = '';
|
||||||
|
$('#new-student-birthday').value = '';
|
||||||
msg('Kid added.');
|
msg('Kid added.');
|
||||||
loadKids();
|
loadKids();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -342,6 +342,9 @@ async function openKid(id, tab = 'today', welcome = false) {
|
|||||||
switchTab(tab);
|
switchTab(tab);
|
||||||
if (welcome) showWelcomeToast(currentStudent.emoji, currentStudent.name);
|
if (welcome) showWelcomeToast(currentStudent.emoji, currentStudent.name);
|
||||||
|
|
||||||
|
// Birthday celebration — show once per day when it's the student's birthday week
|
||||||
|
if (state.student.isBirthdayWeek) showBirthdayCelebration(state.student.name);
|
||||||
|
|
||||||
// Celebrate if all today's tasks are done
|
// Celebrate if all today's tasks are done
|
||||||
if (agenda.tasks.length > 0) {
|
if (agenda.tasks.length > 0) {
|
||||||
const allDone = agenda.tasks.every((t) =>
|
const allDone = agenda.tasks.every((t) =>
|
||||||
@@ -378,6 +381,23 @@ $('#celebration-close').addEventListener('click', () => {
|
|||||||
$('#celebration-overlay').hidden = true;
|
$('#celebration-overlay').hidden = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------- birthday celebration ----------
|
||||||
|
|
||||||
|
function showBirthdayCelebration(name) {
|
||||||
|
const key = `bday_shown_${name}_${todayStr()}`;
|
||||||
|
if (sessionStorage.getItem(key)) return; // only once per session per day
|
||||||
|
sessionStorage.setItem(key, '1');
|
||||||
|
$('#birthday-heading').textContent = `Happy Birthday, ${name}! 🎉`;
|
||||||
|
$('#birthday-message').textContent = 'Wishing you an amazing day full of fun and joy!';
|
||||||
|
$('#birthday-overlay').hidden = false;
|
||||||
|
playFanfare();
|
||||||
|
popConfetti();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#birthday-close').addEventListener('click', () => {
|
||||||
|
$('#birthday-overlay').hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- mini celebration (confetti + toast) ----------
|
// ---------- mini celebration (confetti + toast) ----------
|
||||||
|
|
||||||
let pendingMiniCelebration = false;
|
let pendingMiniCelebration = false;
|
||||||
|
|||||||
@@ -110,6 +110,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Birthday celebration overlay -->
|
||||||
|
<div id="birthday-overlay" hidden>
|
||||||
|
<div id="birthday-content">
|
||||||
|
<div class="birthday-balloons" aria-hidden="true">🎈🎈🎈</div>
|
||||||
|
<div class="birthday-cake">🎂</div>
|
||||||
|
<h2 id="birthday-heading">Happy Birthday!</h2>
|
||||||
|
<p id="birthday-message" class="birthday-message"></p>
|
||||||
|
<button id="birthday-close" class="check-btn">Thanks! 🎉</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Course detail: units + items -->
|
<!-- Course detail: units + items -->
|
||||||
<section id="view-course" class="view" hidden>
|
<section id="view-course" class="view" hidden>
|
||||||
<button class="back-btn" data-back="course">← Back</button>
|
<button class="back-btn" data-back="course">← Back</button>
|
||||||
|
|||||||
@@ -1198,6 +1198,32 @@ body.bg-bubbles {
|
|||||||
.streak-label { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.25rem; }
|
.streak-label { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.25rem; }
|
||||||
.best-streak-label { font-size: 0.85rem; color: var(--gold); font-weight: 700; margin-bottom: 0.5rem; }
|
.best-streak-label { font-size: 0.85rem; color: var(--gold); font-weight: 700; margin-bottom: 0.5rem; }
|
||||||
|
|
||||||
|
/* ---------- birthday overlay ---------- */
|
||||||
|
#birthday-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 210;
|
||||||
|
background: linear-gradient(135deg, rgba(255,182,50,0.92) 0%, rgba(255,80,120,0.92) 100%);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
#birthday-content {
|
||||||
|
background: var(--card); border-radius: var(--radius);
|
||||||
|
padding: 2.5rem 2rem; text-align: center;
|
||||||
|
max-width: 380px; width: 92%;
|
||||||
|
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.28);
|
||||||
|
animation: pop-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
.birthday-balloons {
|
||||||
|
font-size: 2.5rem; letter-spacing: .3rem;
|
||||||
|
animation: balloon-sway 1.8s ease-in-out infinite alternate;
|
||||||
|
display: block; margin-bottom: .25rem;
|
||||||
|
}
|
||||||
|
@keyframes balloon-sway {
|
||||||
|
from { transform: rotate(-6deg) translateY(0); }
|
||||||
|
to { transform: rotate(6deg) translateY(-8px); }
|
||||||
|
}
|
||||||
|
.birthday-cake { font-size: 5rem; display: block; margin: 0.25rem 0; animation: bounce 0.7s ease infinite alternate; }
|
||||||
|
#birthday-content h2 { font-size: 2rem; margin: 0.5rem 0 0.5rem; }
|
||||||
|
.birthday-message { color: var(--muted); font-size: 1.05rem; margin: 0 0 1.5rem; }
|
||||||
|
|
||||||
/* ---------- streak pill on kid home ---------- */
|
/* ---------- streak pill on kid home ---------- */
|
||||||
.streak-pill {
|
.streak-pill {
|
||||||
display: inline-flex; align-items: center; gap: 0.3rem;
|
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||||
|
|||||||
@@ -324,8 +324,26 @@ const GRADABLE_TYPES = ['assignment', 'quiz', 'matching', 'crossword', 'spelling
|
|||||||
|
|
||||||
// ---------- kid picker ----------
|
// ---------- kid picker ----------
|
||||||
|
|
||||||
|
// Returns true if today falls within the Mon–Sun week that contains the birthday (MM-DD)
|
||||||
|
function isBirthdayWeek(birthday) {
|
||||||
|
if (!birthday) return false;
|
||||||
|
const [mm, dd] = birthday.split('-').map(Number);
|
||||||
|
if (!mm || !dd) return false;
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const bday = new Date(year, mm - 1, dd);
|
||||||
|
// Monday of the week containing the birthday
|
||||||
|
const dow = bday.getDay(); // 0=Sun
|
||||||
|
const monday = new Date(bday);
|
||||||
|
monday.setDate(bday.getDate() - (dow === 0 ? 6 : dow - 1));
|
||||||
|
const sunday = new Date(monday);
|
||||||
|
sunday.setDate(monday.getDate() + 6);
|
||||||
|
const todayMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
return todayMidnight >= monday && todayMidnight <= sunday;
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/api/students', (req, res) => {
|
app.get('/api/students', (req, res) => {
|
||||||
res.json(db.prepare(`SELECT id, name, emoji, theme, bg_pattern, streak_count, streak_date, young_learner FROM students ORDER BY name`).all());
|
res.json(db.prepare(`SELECT id, name, emoji, theme, bg_pattern, streak_count, streak_date, young_learner, birthday FROM students ORDER BY name`).all());
|
||||||
});
|
});
|
||||||
|
|
||||||
const VALID_THEMES = ['blue','green','purple','orange','pink','red','teal','yellow','indigo'];
|
const VALID_THEMES = ['blue','green','purple','orange','pink','red','teal','yellow','indigo'];
|
||||||
@@ -376,7 +394,7 @@ app.post('/api/students/:id/complete-day', (req, res) => {
|
|||||||
// Everything the standalone spelling home screen needs
|
// Everything the standalone spelling home screen needs
|
||||||
app.get('/api/state/:studentId', (req, res) => {
|
app.get('/api/state/:studentId', (req, res) => {
|
||||||
const id = req.params.studentId;
|
const id = req.params.studentId;
|
||||||
const student = db.prepare(`SELECT id, name, emoji, theme, bg_pattern, young_learner FROM students WHERE id = ?`).get(id);
|
const student = db.prepare(`SELECT id, name, emoji, theme, bg_pattern, young_learner, birthday FROM students WHERE id = ?`).get(id);
|
||||||
if (!student) return res.status(404).json({ error: 'No such student' });
|
if (!student) return res.status(404).json({ error: 'No such student' });
|
||||||
|
|
||||||
const assignment = db.prepare(`
|
const assignment = db.prepare(`
|
||||||
@@ -402,7 +420,7 @@ app.get('/api/state/:studentId', (req, res) => {
|
|||||||
AND date(p.due) <= date('now')
|
AND date(p.due) <= date('now')
|
||||||
`).get(id, assignment ? assignment.id : -1).n;
|
`).get(id, assignment ? assignment.id : -1).n;
|
||||||
|
|
||||||
res.json({ student, assignment, listProgress, dueReviews });
|
res.json({ student: { ...student, isBirthdayWeek: isBirthdayWeek(student.birthday) }, assignment, listProgress, dueReviews });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build a practice session: the target list's unmastered words + due reviews.
|
// Build a practice session: the target list's unmastered words + due reviews.
|
||||||
@@ -696,19 +714,32 @@ app.get('/api/public-settings', (req, res) => {
|
|||||||
res.json({ show_home_emoji: getSetting('show_home_emoji') !== 'false' });
|
res.json({ show_home_emoji: getSetting('show_home_emoji') !== 'false' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Validate MM-DD format (no year stored — birthday repeats annually)
|
||||||
|
function parseBirthday(val) {
|
||||||
|
if (!val) return null;
|
||||||
|
const m = String(val).match(/^(\d{1,2})-(\d{2})$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const mm = Number(m[1]), dd = Number(m[2]);
|
||||||
|
if (mm < 1 || mm > 12 || dd < 1 || dd > 31) return null;
|
||||||
|
return `${String(mm).padStart(2, '0')}-${String(dd).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
app.post('/api/students', requirePin, (req, res) => {
|
app.post('/api/students', requirePin, (req, res) => {
|
||||||
const name = String(req.body.name || '').trim();
|
const name = String(req.body.name || '').trim();
|
||||||
if (!name) return res.status(400).json({ error: 'Name required' });
|
if (!name) return res.status(400).json({ error: 'Name required' });
|
||||||
const id = db.prepare(`INSERT INTO students (name, emoji) VALUES (?, ?)`)
|
const birthday = parseBirthday(req.body.birthday);
|
||||||
.run(name, req.body.emoji || '🙂').lastInsertRowid;
|
const id = db.prepare(`INSERT INTO students (name, emoji, birthday) VALUES (?, ?, ?)`)
|
||||||
|
.run(name, req.body.emoji || '🙂', birthday).lastInsertRowid;
|
||||||
res.json({ id });
|
res.json({ id });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/students/:id', requirePin, (req, res) => {
|
app.put('/api/students/:id', requirePin, (req, res) => {
|
||||||
const { emoji, theme } = req.body;
|
const { emoji, theme, birthday } = req.body;
|
||||||
if (emoji) db.prepare(`UPDATE students SET emoji = ? WHERE id = ?`).run(String(emoji), req.params.id);
|
if (emoji) db.prepare(`UPDATE students SET emoji = ? WHERE id = ?`).run(String(emoji), req.params.id);
|
||||||
if (theme && VALID_THEMES.includes(theme))
|
if (theme && VALID_THEMES.includes(theme))
|
||||||
db.prepare(`UPDATE students SET theme = ? WHERE id = ?`).run(theme, req.params.id);
|
db.prepare(`UPDATE students SET theme = ? WHERE id = ?`).run(theme, req.params.id);
|
||||||
|
if (birthday !== undefined)
|
||||||
|
db.prepare(`UPDATE students SET birthday = ? WHERE id = ?`).run(parseBirthday(birthday), req.params.id);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user