diff --git a/db.js b/db.js
index 543681a..4a21946 100644
--- a/db.js
+++ b/db.js
@@ -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 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 birthday TEXT`);
tryAlter(`ALTER TABLE lists ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`);
tryAlter(`ALTER TABLE decks ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`);
diff --git a/public/admin.html b/public/admin.html
index 8605b9e..2a8d206 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -54,6 +54,9 @@
+
diff --git a/public/admin.js b/public/admin.js
index 9ec7d46..d06c910 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -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 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 (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() {
const students = await api('/api/students');
$('#student-rows').innerHTML = students.length
@@ -131,6 +152,11 @@ async function loadKids() {
const youngLabel = s.young_learner
? `πΆ Young`
: '';
+ const bdayVal = birthdayToDateInput(s.birthday);
+ const bdayDisplay = s.birthday ? `π ${formatBirthday(s.birthday)}` : 'π β';
+ const summerNote = isSummerBirthday(s.birthday)
+ ? `βοΈ Summer birthday`
+ : '';
return `
`;
})
@@ -151,7 +184,6 @@ async function loadKids() {
const studentId = btn.dataset.studentTheme;
const theme = btn.dataset.theme;
await api(`/api/students/${studentId}`, { method: 'PUT', body: { theme } });
- // Update selected state without full reload
document.querySelectorAll(`[data-student-theme="${studentId}"]`).forEach((b) =>
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) =>
btn.addEventListener('click', async () => {
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();
const name = $('#new-student-name').value.trim();
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
+ 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-birthday').value = '';
msg('Kid added.');
loadKids();
});
diff --git a/public/app.js b/public/app.js
index 6ea240c..7e93e88 100644
--- a/public/app.js
+++ b/public/app.js
@@ -342,6 +342,9 @@ async function openKid(id, tab = 'today', welcome = false) {
switchTab(tab);
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
if (agenda.tasks.length > 0) {
const allDone = agenda.tasks.every((t) =>
@@ -378,6 +381,23 @@ $('#celebration-close').addEventListener('click', () => {
$('#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) ----------
let pendingMiniCelebration = false;
diff --git a/public/index.html b/public/index.html
index db1ea32..89823e0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -110,6 +110,17 @@
+
+
+
+
πππ
+
π
+
Happy Birthday!
+
+
+
+
+
diff --git a/public/styles.css b/public/styles.css
index 320dee0..2167a34 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -1198,6 +1198,32 @@ body.bg-bubbles {
.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; }
+/* ---------- 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 {
display: inline-flex; align-items: center; gap: 0.3rem;
diff --git a/server.js b/server.js
index 529f9f1..2b75a9f 100644
--- a/server.js
+++ b/server.js
@@ -324,8 +324,26 @@ const GRADABLE_TYPES = ['assignment', 'quiz', 'matching', 'crossword', 'spelling
// ---------- 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) => {
- 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'];
@@ -376,7 +394,7 @@ app.post('/api/students/:id/complete-day', (req, res) => {
// Everything the standalone spelling home screen needs
app.get('/api/state/:studentId', (req, res) => {
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' });
const assignment = db.prepare(`
@@ -402,7 +420,7 @@ app.get('/api/state/:studentId', (req, res) => {
AND date(p.due) <= date('now')
`).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.
@@ -696,19 +714,32 @@ app.get('/api/public-settings', (req, res) => {
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) => {
const name = String(req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'Name required' });
- const id = db.prepare(`INSERT INTO students (name, emoji) VALUES (?, ?)`)
- .run(name, req.body.emoji || 'π').lastInsertRowid;
+ const birthday = parseBirthday(req.body.birthday);
+ const id = db.prepare(`INSERT INTO students (name, emoji, birthday) VALUES (?, ?, ?)`)
+ .run(name, req.body.emoji || 'π', birthday).lastInsertRowid;
res.json({ id });
});
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 (theme && VALID_THEMES.includes(theme))
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 });
});