diff --git a/public/components/admin.html b/public/components/admin.html index 9edf3e8..1c69203 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -263,6 +263,64 @@ + +
+
+

Developmental Milestones

+ +
+
+ +
+ + + +
+ + +
+ + + +
+
+

Reset Settings

diff --git a/public/index.html b/public/index.html index c2dd2ec..95c8edd 100644 --- a/public/index.html +++ b/public/index.html @@ -321,6 +321,7 @@ + diff --git a/public/js/adminMilestones.js b/public/js/adminMilestones.js new file mode 100644 index 0000000..b355578 --- /dev/null +++ b/public/js/adminMilestones.js @@ -0,0 +1,251 @@ +// ============================================================ +// ADMIN: DEVELOPMENTAL MILESTONES MANAGEMENT +// ============================================================ + +(function() { + var _inited = false; + + document.addEventListener('tabChanged', function(e) { + if (e.detail.tab !== 'admin' || _inited) return; + _inited = true; + initMilestonesAdmin(); + }); + + var milestones = []; + var ageGroups = []; + var domains = []; + var editingId = null; + + function initMilestonesAdmin() { + console.log('[AdminMilestones] Initializing...'); + + // Buttons + document.getElementById('btn-refresh-milestones')?.addEventListener('click', loadMilestones); + document.getElementById('btn-add-milestone')?.addEventListener('click', function() { + editingId = null; + openMilestoneModal(); + }); + document.getElementById('btn-close-milestone-modal')?.addEventListener('click', closeMilestoneModal); + document.getElementById('btn-cancel-milestone-modal')?.addEventListener('click', closeMilestoneModal); + document.getElementById('btn-save-milestone')?.addEventListener('click', saveMilestone); + + // Filter + document.getElementById('ms-filter-age')?.addEventListener('change', renderMilestones); + + // Load data + loadMetadata(); + loadMilestones(); + } + + function loadMetadata() { + fetch('/api/admin/milestones/meta', { + headers: getAuthHeaders() + }) + .then(r => r.json()) + .then(data => { + if (!data.success) return; + ageGroups = data.age_groups || []; + domains = data.domains || []; + + // Populate filter dropdown + var filterSelect = document.getElementById('ms-filter-age'); + if (filterSelect) { + filterSelect.innerHTML = ''; + ageGroups.forEach(function(age) { + var opt = document.createElement('option'); + opt.value = age; + opt.textContent = age; + filterSelect.appendChild(opt); + }); + } + + // Populate datalists for modal + var ageDatalist = document.getElementById('ms-age-list'); + if (ageDatalist) { + ageDatalist.innerHTML = ''; + ageGroups.forEach(function(age) { + var opt = document.createElement('option'); + opt.value = age; + ageDatalist.appendChild(opt); + }); + } + + var domainDatalist = document.getElementById('ms-domain-list'); + if (domainDatalist) { + domainDatalist.innerHTML = ''; + domains.forEach(function(domain) { + var opt = document.createElement('option'); + opt.value = domain; + domainDatalist.appendChild(opt); + }); + } + }) + .catch(err => console.error('[AdminMilestones] Failed to load metadata:', err)); + } + + function loadMilestones() { + var filterAge = document.getElementById('ms-filter-age')?.value; + var url = '/api/admin/milestones' + (filterAge ? '?age_group=' + encodeURIComponent(filterAge) : ''); + + fetch(url, { + headers: getAuthHeaders() + }) + .then(r => r.json()) + .then(data => { + if (!data.success) { + showToast(data.error || 'Failed to load milestones', 'error'); + return; + } + milestones = data.milestones || []; + renderMilestones(); + }) + .catch(err => { + console.error('[AdminMilestones] Failed to load:', err); + showToast('Failed to load milestones: ' + err.message, 'error'); + }); + } + + function renderMilestones() { + var list = document.getElementById('milestones-list'); + if (!list) return; + + var filterAge = document.getElementById('ms-filter-age')?.value; + var filtered = filterAge ? milestones.filter(m => m.age_group === filterAge) : milestones; + + if (filtered.length === 0) { + list.innerHTML = '

No milestones found. Click "Add Milestone" to create one.

'; + return; + } + + // Group by age_group and domain + var grouped = {}; + filtered.forEach(function(m) { + if (!grouped[m.age_group]) grouped[m.age_group] = {}; + if (!grouped[m.age_group][m.domain]) grouped[m.age_group][m.domain] = []; + grouped[m.age_group][m.domain].push(m); + }); + + var html = ''; + Object.keys(grouped).forEach(function(age) { + html += '
'; + html += '

' + age + '

'; + + Object.keys(grouped[age]).forEach(function(domain) { + html += '
'; + html += '
' + domain + '
'; + + grouped[age][domain].forEach(function(m) { + html += '
'; + html += '' + m.milestone_text + ''; + html += ''; + html += ''; + html += '
'; + }); + + html += '
'; + }); + + html += '
'; + }); + + list.innerHTML = html; + } + + function openMilestoneModal(milestone) { + var modal = document.getElementById('milestone-modal'); + var title = document.getElementById('milestone-modal-title'); + + if (!modal) return; + + if (milestone) { + title.innerHTML = ' Edit Milestone'; + document.getElementById('ms-edit-id').value = milestone.id; + document.getElementById('ms-edit-age').value = milestone.age_group; + document.getElementById('ms-edit-domain').value = milestone.domain; + document.getElementById('ms-edit-text').value = milestone.milestone_text; + document.getElementById('ms-edit-sort').value = milestone.sort_order; + } else { + title.innerHTML = ' Add Milestone'; + document.getElementById('ms-edit-id').value = ''; + document.getElementById('ms-edit-age').value = ''; + document.getElementById('ms-edit-domain').value = ''; + document.getElementById('ms-edit-text').value = ''; + document.getElementById('ms-edit-sort').value = '0'; + } + + modal.style.display = 'flex'; + } + + function closeMilestoneModal() { + var modal = document.getElementById('milestone-modal'); + if (modal) modal.style.display = 'none'; + } + + function saveMilestone() { + var id = document.getElementById('ms-edit-id').value; + var age_group = document.getElementById('ms-edit-age').value.trim(); + var domain = document.getElementById('ms-edit-domain').value.trim(); + var milestone_text = document.getElementById('ms-edit-text').value.trim(); + var sort_order = parseInt(document.getElementById('ms-edit-sort').value) || 0; + + if (!age_group || !domain || !milestone_text) { + showToast('Please fill in all fields', 'error'); + return; + } + + var method = id ? 'PUT' : 'POST'; + var url = id ? '/api/admin/milestones/' + id : '/api/admin/milestones'; + + fetch(url, { + method: method, + headers: getAuthHeaders(), + body: JSON.stringify({ age_group, domain, milestone_text, sort_order }) + }) + .then(r => r.json()) + .then(data => { + if (!data.success) { + showToast(data.error || 'Failed to save', 'error'); + return; + } + showToast(id ? 'Milestone updated' : 'Milestone added', 'success'); + closeMilestoneModal(); + loadMetadata(); + loadMilestones(); + }) + .catch(err => { + console.error('[AdminMilestones] Save error:', err); + showToast('Failed to save: ' + err.message, 'error'); + }); + } + + window.editMilestone = function(id) { + var milestone = milestones.find(m => m.id === id); + if (!milestone) return; + editingId = id; + openMilestoneModal(milestone); + }; + + window.deleteMilestone = function(id) { + if (!confirm('Delete this milestone? This action cannot be undone.')) return; + + fetch('/api/admin/milestones/' + id, { + method: 'DELETE', + headers: getAuthHeaders() + }) + .then(r => r.json()) + .then(data => { + if (!data.success) { + showToast(data.error || 'Failed to delete', 'error'); + return; + } + showToast('Milestone deleted', 'success'); + loadMetadata(); + loadMilestones(); + }) + .catch(err => { + console.error('[AdminMilestones] Delete error:', err); + showToast('Failed to delete: ' + err.message, 'error'); + }); + }; + +})(); diff --git a/public/js/milestones.js b/public/js/milestones.js index 2451c60..c5c521d 100644 --- a/public/js/milestones.js +++ b/public/js/milestones.js @@ -1,8 +1,14 @@ (function() { var _inited = false; + var MILESTONES_DATA = {}; // Will be loaded from API + document.addEventListener('tabChanged', function(e) { if (e.detail.tab !== 'wellvisit' || _inited) return; _inited = true; + initMilestones(); + }); + + function initMilestones() { var ageSelect = document.getElementById('ms-age-group'); var checklist = document.getElementById('milestone-checklist'); var actionsBar = document.getElementById('milestone-actions'); @@ -16,6 +22,38 @@ var state = {}; + // Load milestones from API + fetch('/api/milestones-data', { + headers: getAuthHeaders() + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.success) { + MILESTONES_DATA = data.milestones; + populateAgeGroups(); + } else { + console.error('[Milestones] Failed to load:', data.error); + showToast('Failed to load milestones data', 'error'); + } + }) + .catch(function(err) { + console.error('[Milestones] Load error:', err); + showToast('Failed to load milestones: ' + err.message, 'error'); + }); + + function populateAgeGroups() { + // Populate age group dropdown + if (ageSelect && Object.keys(MILESTONES_DATA).length > 0) { + ageSelect.innerHTML = ''; + Object.keys(MILESTONES_DATA).forEach(function(age) { + var opt = document.createElement('option'); + opt.value = age; + opt.textContent = age; + ageSelect.appendChild(opt); + }); + } + } + ageSelect.addEventListener('change', function() { var age = ageSelect.value; state = {}; @@ -185,5 +223,5 @@ }); console.log('✅ Milestones module loaded'); - }); + } // end initMilestones })(); diff --git a/scripts/import-milestones.js b/scripts/import-milestones.js new file mode 100755 index 0000000..58a9fe5 --- /dev/null +++ b/scripts/import-milestones.js @@ -0,0 +1,197 @@ +#!/usr/bin/env node +/** + * Import developmental milestones from static data file into database + * Run: node scripts/import-milestones.js + */ + +require('dotenv').config(); +const db = require('../src/db/database'); + +// Import static data +const MILESTONES_DATA = { + "Newborn / 1 month": { + "Gross Motor": [ + "Moves arms and legs equally", + "Lifts head briefly when on tummy (prone)", + "Strong flexion posture (arms and legs tucked)", + "Turns head side to side when lying on back" + ], + "Fine Motor": [ + "Hands mostly fisted", + "Strong grasp reflex when palm is touched", + "Brings hands near face" + ], + "Language": [ + "Cries to express needs", + "Startles or quiets to sounds", + "Makes brief throaty sounds" + ], + "Social/Emotional": [ + "Recognizes caregiver voice", + "Briefly fixates on faces at close range (8-12 inches)", + "Calms when held or hears familiar voice", + "Shows brief alert periods" + ], + "Cognitive": [ + "Focuses on faces briefly", + "Follows objects briefly to midline", + "Prefers black and white or high-contrast patterns", + "Responds to loud sounds (Moro reflex)" + ] + }, + + "2 months": { + "Gross Motor": [ + "Lifts head when prone (45 degrees)", + "Holds head steady when held upright briefly", + "Moves both arms and both legs", + "Pushes up on tummy" + ], + "Fine Motor": [ + "Opens hands briefly", + "Holds rattle if placed in hand briefly", + "Brings hands to midline" + ], + "Language": [ + "Coos and makes gurgling sounds", + "Makes sounds other than crying", + "Turns head toward sounds" + ], + "Social/Emotional": [ + "Social smile (responsive to faces)", + "Begins to self-soothe (hands to mouth)", + "Tries to look at parent", + "Calms when spoken to or picked up" + ], + "Cognitive": [ + "Pays attention to faces", + "Begins to follow things with eyes", + "Recognizes people at a distance" + ] + }, + + "4 months": { + "Gross Motor": [ + "Holds head steady unsupported", + "Pushes up to elbows when on tummy", + "May roll over tummy to back", + "Pushes down on legs when feet on hard surface" + ], + "Fine Motor": [ + "Reaches for toys with one hand", + "Uses hands and eyes together", + "Grasps and shakes hand toys" + ], + "Language": [ + "Babbles with expression and copies sounds", + "Cries differently for hunger pain and tiredness", + "Makes vowel sounds ah eh oh" + ], + "Social/Emotional": [ + "Smiles spontaneously especially at people", + "Likes to play and may cry when playing stops", + "Copies movements and facial expressions" + ], + "Cognitive": [ + "Lets you know if happy or sad", + "Follows moving things with eyes side to side", + "Watches faces closely", + "Recognizes familiar people at a distance" + ] + }, + + "6 months": { + "Gross Motor": [ + "Rolls over in both directions", + "Begins to sit without support", + "Supports weight on legs and might bounce", + "Rocks back and forth on hands and knees" + ], + "Fine Motor": [ + "Reaches for and grasps objects", + "Transfers objects hand to hand", + "Raking grasp to pick up objects", + "Brings things to mouth" + ], + "Language": [ + "Responds to own name", + "Responds to sounds by making sounds", + "Strings vowels together when babbling", + "Begins consonant sounds m and b", + "Makes sounds to show joy and displeasure" + ], + "Social/Emotional": [ + "Knows familiar faces and recognizes strangers", + "Likes to play with others especially parents", + "Responds to other peoples emotions", + "Likes to look at self in mirror" + ], + "Cognitive": [ + "Looks around at things nearby", + "Shows curiosity and tries to get things out of reach", + "Begins to pass things from one hand to the other" + ] + } + // Add more age groups as needed... +}; + +async function importMilestones() { + console.log('🚀 Starting milestones import...\n'); + + try { + // Check if milestones already exist + const existing = await db.query('SELECT COUNT(*) as count FROM developmental_milestones'); + if (parseInt(existing.rows[0].count) > 0) { + console.log(`⚠️ Database already contains ${existing.rows[0].count} milestones.`); + console.log(' Clear existing data first or skip import.'); + const readline = require('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + await new Promise((resolve) => { + rl.question('Clear existing data and re-import? (yes/no): ', (answer) => { + rl.close(); + if (answer.toLowerCase() !== 'yes') { + console.log('Import cancelled.'); + process.exit(0); + } + resolve(); + }); + }); + + await db.query('DELETE FROM developmental_milestones'); + console.log('✅ Cleared existing milestones\n'); + } + + let imported = 0; + let sortOrder = 0; + + for (const ageGroup in MILESTONES_DATA) { + console.log(`📋 Importing: ${ageGroup}`); + + for (const domain in MILESTONES_DATA[ageGroup]) { + const milestones = MILESTONES_DATA[ageGroup][domain]; + + for (const milestone of milestones) { + await db.query( + `INSERT INTO developmental_milestones (age_group, domain, milestone_text, sort_order) + VALUES ($1, $2, $3, $4)`, + [ageGroup, domain, milestone, sortOrder] + ); + sortOrder++; + imported++; + } + } + } + + console.log(`\n✅ Import complete!`); + console.log(` Imported: ${imported} milestones`); + console.log(`\nYou can now manage milestones via the Admin dashboard.\n`); + + process.exit(0); + } catch (err) { + console.error('❌ Import failed:', err.message); + process.exit(1); + } +} + +importMilestones(); diff --git a/server.js b/server.js index 66f918e..9e069b4 100644 --- a/server.js +++ b/server.js @@ -134,6 +134,7 @@ app.use('/api/admin/learning', require('./src/routes/learningAdmin')); app.use('/api/admin', require('./src/routes/admin')); app.use('/api/admin', require('./src/routes/adminConfig')); +app.use('/api/admin', require('./src/routes/adminMilestones')); // Public endpoints — must come BEFORE any router that applies authMiddleware to /api/* const { getAvailableModels, activeProvider: modelsProvider } = require('./src/utils/models'); diff --git a/src/db/database.js b/src/db/database.js index 5480398..95d4499 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -268,6 +268,24 @@ async function initDatabase() { console.warn('⚠️ Could not add embedding column:', e.message); } + // Developmental milestones table + try { await client.query(` + CREATE TABLE IF NOT EXISTS developmental_milestones ( + id SERIAL PRIMARY KEY, + age_group TEXT NOT NULL, + domain TEXT NOT NULL, + milestone_text TEXT NOT NULL, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_milestones_age_domain ON developmental_milestones(age_group, domain); + `); + console.log('✅ developmental_milestones: table ready'); + } catch(e) { + console.warn('⚠️ Could not create milestones table:', e.message); + } + // Create IVFFLAT index for fast similarity search (after data is populated) try { var indexExists = await client.query( diff --git a/src/routes/adminMilestones.js b/src/routes/adminMilestones.js new file mode 100644 index 0000000..e407df2 --- /dev/null +++ b/src/routes/adminMilestones.js @@ -0,0 +1,144 @@ +const express = require('express'); +const router = express.Router(); +const { adminMiddleware } = require('../middleware/auth'); +const db = require('../db/database'); + +// Get all milestones (optionally filtered by age group) +router.get('/milestones', adminMiddleware, async (req, res) => { + try { + const { age_group } = req.query; + let query = 'SELECT * FROM developmental_milestones'; + let params = []; + + if (age_group) { + query += ' WHERE age_group = $1'; + params.push(age_group); + } + + query += ' ORDER BY age_group, domain, sort_order, id'; + + const result = await db.query(query, params); + res.json({ success: true, milestones: result.rows }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get unique age groups and domains +router.get('/milestones/meta', adminMiddleware, async (req, res) => { + try { + const ageGroups = await db.query( + 'SELECT DISTINCT age_group FROM developmental_milestones ORDER BY age_group' + ); + const domains = await db.query( + 'SELECT DISTINCT domain FROM developmental_milestones ORDER BY domain' + ); + + res.json({ + success: true, + age_groups: ageGroups.rows.map(r => r.age_group), + domains: domains.rows.map(r => r.domain) + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Create new milestone +router.post('/milestones', adminMiddleware, async (req, res) => { + try { + const { age_group, domain, milestone_text, sort_order } = req.body; + + if (!age_group || !domain || !milestone_text) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + const result = await db.query( + `INSERT INTO developmental_milestones (age_group, domain, milestone_text, sort_order) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [age_group, domain, milestone_text, sort_order || 0] + ); + + res.json({ success: true, milestone: result.rows[0] }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Update milestone +router.put('/milestones/:id', adminMiddleware, async (req, res) => { + try { + const { id } = req.params; + const { age_group, domain, milestone_text, sort_order } = req.body; + + if (!age_group || !domain || !milestone_text) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + const result = await db.query( + `UPDATE developmental_milestones + SET age_group = $1, domain = $2, milestone_text = $3, sort_order = $4, updated_at = NOW() + WHERE id = $5 + RETURNING *`, + [age_group, domain, milestone_text, sort_order || 0, id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Milestone not found' }); + } + + res.json({ success: true, milestone: result.rows[0] }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Delete milestone +router.delete('/milestones/:id', adminMiddleware, async (req, res) => { + try { + const { id } = req.params; + + const result = await db.query( + 'DELETE FROM developmental_milestones WHERE id = $1 RETURNING *', + [id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Milestone not found' }); + } + + res.json({ success: true, message: 'Milestone deleted' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Bulk import milestones (from static data) +router.post('/milestones/bulk-import', adminMiddleware, async (req, res) => { + try { + const { milestones } = req.body; + + if (!Array.isArray(milestones)) { + return res.status(400).json({ error: 'Expected array of milestones' }); + } + + let imported = 0; + for (const m of milestones) { + if (!m.age_group || !m.domain || !m.milestone_text) continue; + + await db.query( + `INSERT INTO developmental_milestones (age_group, domain, milestone_text, sort_order) + VALUES ($1, $2, $3, $4)`, + [m.age_group, m.domain, m.milestone_text, m.sort_order || 0] + ); + imported++; + } + + res.json({ success: true, imported }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; diff --git a/src/routes/milestones.js b/src/routes/milestones.js index 1835d3a..40b85db 100644 --- a/src/routes/milestones.js +++ b/src/routes/milestones.js @@ -3,6 +3,32 @@ const router = express.Router(); const { callAI } = require('../utils/ai'); const PROMPTS = require('../utils/prompts'); const { authMiddleware } = require('../middleware/auth'); +const db = require('../db/database'); + +// Get all milestones for client (authenticated users) +router.get('/milestones-data', authMiddleware, async (req, res) => { + try { + const result = await db.query( + 'SELECT * FROM developmental_milestones ORDER BY age_group, domain, sort_order, id' + ); + + // Group by age_group and domain to match the static data structure + const grouped = {}; + result.rows.forEach(row => { + if (!grouped[row.age_group]) { + grouped[row.age_group] = {}; + } + if (!grouped[row.age_group][row.domain]) { + grouped[row.age_group][row.domain] = []; + } + grouped[row.age_group][row.domain].push(row.milestone_text); + }); + + res.json({ success: true, milestones: grouped }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); // Narrative format router.post('/generate-milestone-narrative', authMiddleware, async (req, res) => {