From 630936fac37ea520de378f86e5a27f866bc538e3 Mon Sep 17 00:00:00 2001 From: haopengzhan Date: Sun, 23 Aug 2026 11:40:26 -0700 Subject: [PATCH] feat: enhance action differentiators, deep video extraction, and full action type parsing --- dashboard/dashboard.js | 285 +++++++++++++++++++++++++++++---- dashboard/viewer_template.html | 13 +- 2 files changed, 261 insertions(+), 37 deletions(-) diff --git a/dashboard/dashboard.js b/dashboard/dashboard.js index 2eec599..eb3bd5f 100644 --- a/dashboard/dashboard.js +++ b/dashboard/dashboard.js @@ -516,50 +516,257 @@ async function fetchAllActivities(studentId) { function matchesSelectedCategories(act) { const type = (act.activity_type || act.action_type || act.type || '').toLowerCase(); - if (act.media || act.image_url || act.video_url || type.includes('photo') || type.includes('video')) { + if (type.includes('photo') || type.includes('video') || act.media || act.image_url || act.video_url || act.video_stream_url) { if (el.catPhotos.checked) return true; } - if (type.includes('food') || type.includes('meal') || type.includes('nap') || type.includes('potty') || type.includes('diaper') || type.includes('med')) { + if (type.includes('food') || type.includes('meal') || type.includes('nap') || type.includes('potty') || type.includes('diaper') || type.includes('med') || type.includes('bottle')) { if (el.catCare.checked) return true; } - if (type.includes('milestone') || type.includes('learn') || type.includes('observation') || type.includes('kudos')) { + if (type.includes('milestone') || type.includes('learn') || type.includes('observation') || type.includes('kudos') || type.includes('skill')) { if (el.catMilestones.checked) return true; } - if (type.includes('checkin') || type.includes('checkout') || type.includes('attendance') || type.includes('health') || type.includes('incident')) { + if (type.includes('checkin') || type.includes('checkout') || type.includes('attendance') || type.includes('health') || type.includes('incident') || type.includes('screening') || type.includes('name_to_face')) { if (el.catAttendance.checked) return true; } - if (type.includes('note') || type.includes('message') || type.includes('announcement')) { + if (type.includes('note') || type.includes('message') || type.includes('announcement') || type.includes('reminder') || type.includes('custom')) { if (el.catMessages.checked) return true; } + // Default: include all other action types return true; } +// Deep recursive media extractor to find high-res photos and videos anywhere in object/array +function extractMediaUrls(act) { + let imageUrl = null; + let videoUrl = null; + let thumbUrl = null; + + const isVideoStr = (url) => { + if (!url || typeof url !== 'string') return false; + const lower = url.toLowerCase(); + return lower.includes('.mp4') || lower.includes('.mov') || lower.includes('.webm') || lower.includes('/videos/') || lower.includes('video_stream') || lower.includes('transcoded_video') || lower.includes('media_videos'); + }; + + const isImageStr = (url) => { + if (!url || typeof url !== 'string') return false; + const lower = url.toLowerCase(); + return lower.includes('.jpg') || lower.includes('.jpeg') || lower.includes('.png') || lower.includes('.webp') || lower.includes('.gif') || lower.includes('/images/') || lower.includes('/photos/') || lower.includes('media_images'); + }; + + function search(obj, depth = 0) { + if (!obj || depth > 5) return; + + if (typeof obj === 'string') { + if (obj.startsWith('http://') || obj.startsWith('https://')) { + if (isVideoStr(obj) && !videoUrl) videoUrl = obj; + else if (isImageStr(obj) && !imageUrl) imageUrl = obj; + else if (!imageUrl && !videoUrl) { + if (act.activity_type === 'ac_video' || act.type === 'video') videoUrl = obj; + else imageUrl = obj; + } + } + return; + } + + if (Array.isArray(obj)) { + for (const item of obj) search(item, depth + 1); + return; + } + + if (typeof obj === 'object') { + // Check video keys + const possibleVideos = [ + obj.video_url, obj.video_stream_url, obj.video_hd_url, obj.video_transcode_url, + obj.transcoded_video_url, obj.original_video_url, obj.stream_url, obj.mp4_url, + obj.video, obj.source_url, obj.download_url + ]; + for (const v of possibleVideos) { + if (typeof v === 'string' && v.startsWith('http')) { + if (!videoUrl) videoUrl = v; + break; + } + } + + // Check image keys + const possibleImages = [ + obj.image_url, obj.photo_url, obj.full_url, obj.picture_url, + obj.original_url, obj.cover_url, obj.large_url + ]; + for (const img of possibleImages) { + if (typeof img === 'string' && img.startsWith('http')) { + if (!imageUrl && img !== videoUrl) imageUrl = img; + break; + } + } + + // Check generic url + if (typeof obj.url === 'string' && obj.url.startsWith('http')) { + if (isVideoStr(obj.url) || act.activity_type === 'ac_video' || act.type === 'video') { + if (!videoUrl) videoUrl = obj.url; + } else if (!imageUrl) { + imageUrl = obj.url; + } + } + + // Check thumbnail + const possibleThumbs = [obj.thumb_url, obj.thumbnail_url, obj.thumbnail, obj.preview_url, obj.preview_image_url]; + for (const th of possibleThumbs) { + if (typeof th === 'string' && th.startsWith('http') && !thumbUrl) { + thumbUrl = th; + break; + } + } + + // Recurse into nested common containers + if (obj.media) search(obj.media, depth + 1); + if (obj.video_data) search(obj.video_data, depth + 1); + if (obj.attachments) search(obj.attachments, depth + 1); + if (obj.items) search(obj.items, depth + 1); + if (obj.photos) search(obj.photos, depth + 1); + if (obj.videos) search(obj.videos, depth + 1); + if (obj.assets) search(obj.assets, depth + 1); + } + } + + // Scan root act and common child objects + search(act); + + // If it's an ac_video activity and imageUrl was found but points to video, fix assignment + if ((act.activity_type === 'ac_video' || act.type === 'video') && !videoUrl && imageUrl) { + videoUrl = imageUrl; + imageUrl = null; + } + + return { imageUrl, videoUrl, thumbUrl }; +} + function normalizeActivity(act) { - const mediaObj = act.media || {}; - const highResImage = mediaObj.image_url || act.image_url || mediaObj.url || act.url; - const highResVideo = mediaObj.video_url || act.video_url; + const { imageUrl, videoUrl, thumbUrl } = extractMediaUrls(act); + const actType = (act.activity_type || act.action_type || act.type || 'activity').toLowerCase(); + const rawSubtype = (act.action_type || act.event_type || act.subtype || act.sub_type || act.action || act.state || act.status || '').toLowerCase(); + + // Extract staff / actor name + const teacherName = act.actor_name || act.created_by?.name || act.teacher_name || act.staff_name || (act.actor ? `${act.actor.first_name || ''} ${act.actor.last_name || ''}`.trim() : '') || ''; + + // Parse Subtypes and Human-readable Summaries for each category + let actionSummary = ''; + let actionSubtype = rawSubtype; + let foodType = act.food_type || act.meal_type || act.menu_type || null; + let amountEaten = act.amount_eaten || act.quantity || act.amount || null; + let napStart = act.start_time || act.nap_start || act.started_at || null; + let napEnd = act.end_time || act.nap_end || act.ended_at || null; + let durationMinutes = act.duration_minutes || act.duration_in_minutes || act.duration || null; + let pottyType = act.potty_type || act.diaper_type || act.potty || act.diaper || null; + let milestoneName = act.milestone_name || act.tag_name || act.skill_name || act.milestone || null; + let attendanceInfo = ''; + + // 1. NAP + if (actType.includes('nap')) { + if (rawSubtype.includes('start') || (napStart && !napEnd && !durationMinutes)) { + actionSubtype = 'start_nap'; + actionSummary = 'Nap Started'; + } else if (rawSubtype.includes('end') || rawSubtype.includes('wake') || napEnd || durationMinutes) { + actionSubtype = 'end_nap'; + const durText = durationMinutes ? `${durationMinutes} mins` : ''; + actionSummary = durText ? `Nap Ended (${durText})` : 'Nap Ended'; + } else { + actionSubtype = 'nap'; + actionSummary = durationMinutes ? `Nap (${durationMinutes} mins)` : 'Nap Log'; + } + } + // 2. CHECK-IN / ATTENDANCE + else if (actType.includes('checkin') || actType.includes('checkout') || actType.includes('attendance')) { + const isOut = rawSubtype.includes('out') || rawSubtype.includes('away') || rawSubtype.includes('pickup') || actType.includes('checkout'); + const signedBy = act.signed_in_by || act.signed_out_by || act.guardian_name || act.actor_name || teacherName || ''; + if (isOut) { + actionSubtype = 'signed_out'; + actionSummary = signedBy ? `Checked Out / Signed Out (by ${signedBy})` : 'Checked Out / Signed Out'; + } else { + actionSubtype = 'signed_in'; + actionSummary = signedBy ? `Checked In / Signed In (by ${signedBy})` : 'Checked In / Signed In'; + } + attendanceInfo = signedBy ? `Signed by: ${signedBy}` : ''; + } + // 3. FOOD / MEALS + else if (actType.includes('food') || actType.includes('meal')) { + if (!foodType && rawSubtype) foodType = rawSubtype; + const mealLabel = foodType ? capitalize(foodType) : 'Meal'; + const eatLabel = amountEaten ? `Ate ${amountEaten}` : ''; + actionSubtype = foodType || 'meal'; + actionSummary = eatLabel ? `${mealLabel} (${eatLabel})` : mealLabel; + } + // 4. POTTY / DIAPER + else if (actType.includes('potty') || actType.includes('diaper')) { + if (!pottyType && rawSubtype) pottyType = rawSubtype; + const pLabel = pottyType ? capitalize(pottyType) : 'Check'; + actionSubtype = pottyType || 'potty'; + actionSummary = `Potty / Diaper (${pLabel})`; + } + // 5. PHOTOS & VIDEOS + else if (actType.includes('video') || videoUrl) { + actionSubtype = 'video'; + actionSummary = act.note ? `Video: "${act.note}"` : 'Video Clip'; + } else if (actType.includes('photo') || imageUrl) { + actionSubtype = 'photo'; + actionSummary = act.note ? `Photo: "${act.note}"` : 'Photo'; + } + // 6. MILESTONES & OBSERVATIONS + else if (actType.includes('milestone') || actType.includes('observation') || actType.includes('learn') || actType.includes('kudos')) { + actionSubtype = 'milestone'; + actionSummary = milestoneName ? `Milestone: ${milestoneName}` : (act.title || 'Learning Milestone'); + } + // 7. MEDICATIONS + else if (actType.includes('med')) { + actionSubtype = 'medication'; + actionSummary = `Medication Administered: ${act.medication_name || act.title || ''}`; + } + // 8. INCIDENTS + else if (actType.includes('incident')) { + actionSubtype = 'incident'; + actionSummary = `Incident Report: ${act.title || act.incident_type || ''}`; + } + // 9. HEALTH / SCREENING + else if (actType.includes('health') || actType.includes('screening')) { + actionSubtype = 'health_screening'; + actionSummary = act.temperature ? `Health Screening (Temp: ${act.temperature}°)` : 'Health Screening'; + attendanceInfo = act.temperature ? `Temp: ${act.temperature}°` : ''; + } + // 10. DEFAULT / NOTES + else { + actionSubtype = rawSubtype || actType; + actionSummary = act.title || act.note || capitalize(actType.replace('ac_', '')); + } return { id: act.id || act.object_id || Math.random().toString(36).substring(2), created_at: act.created_at || act.event_date || new Date().toISOString(), - activity_type: act.activity_type || act.action_type || act.type || 'activity', + activity_type: act.activity_type || actType, + action_subtype: actionSubtype, + action_summary: actionSummary, title: act.title || '', - note: act.note || act.description || act.body || '', - teacher_name: act.actor_name || act.created_by?.name || act.teacher_name || '', - image_url: highResImage || null, - video_url: highResVideo || null, - thumb_url: mediaObj.thumb_url || act.thumb_url || null, - food_type: act.food_type || act.meal_type || null, - amount_eaten: act.amount_eaten || act.quantity || null, - nap_start: act.start_time || act.nap_start || null, - nap_end: act.end_time || act.nap_end || null, - duration_minutes: act.duration_minutes || null, - potty_type: act.potty_type || act.diaper_type || null, - milestone_name: act.milestone_name || act.tag_name || null, - media_local_path: null // Populated during ZIP bundling + note: act.note || act.description || act.body || act.comment || '', + teacher_name: teacherName, + image_url: imageUrl || null, + video_url: videoUrl || null, + thumb_url: thumbUrl || null, + food_type: foodType, + amount_eaten: amountEaten, + nap_start: napStart, + nap_end: napEnd, + duration_minutes: durationMinutes, + potty_type: pottyType, + milestone_name: milestoneName, + attendance_info: attendanceInfo, + media_local_path: null, // Populated during ZIP bundling + raw: act // Preserve 100% full raw backend payload }; } +function capitalize(s) { + if (!s || typeof s !== 'string') return ''; + return s.charAt(0).toUpperCase() + s.slice(1).replace(/_/g, ' '); +} + // ------------------------------------------------------------- // Media Downloader with Concurrency & Progress // ------------------------------------------------------------- @@ -570,7 +777,7 @@ function extractMediaQueue() { state.activities.forEach((act) => { const url = act.video_url || act.image_url; if (url) { - const isVideo = !!act.video_url || url.includes('.mp4') || act.activity_type === 'video'; + const isVideo = !!act.video_url || act.activity_type === 'ac_video' || act.activity_type === 'video' || url.includes('.mp4'); const ext = isVideo ? 'mp4' : 'jpg'; const datePrefix = (act.created_at || '').substring(0, 10) || 'unknown_date'; const filename = `${datePrefix}_${act.id.substring(0, 12)}.${ext}`; @@ -583,6 +790,7 @@ function extractMediaQueue() { state.mediaQueue.push({ activityId: act.id, url: url, + isVideo: isVideo, filename: filename, localZipPath: localZipPath, blob: null, @@ -600,6 +808,10 @@ async function downloadAllMediaFiles() { const concurrency = 3; let activeIndex = 0; + const videoCount = queue.filter(q => q.isVideo).length; + const photoCount = queue.length - videoCount; + log(`Media to download: ${photoCount} photos, ${videoCount} videos.`, 'info'); + updateProgress(35, `Downloading 0 / ${queue.length} media files...`); async function worker() { @@ -622,7 +834,7 @@ async function downloadAllMediaFiles() { } catch (err) { item.status = 'failed'; state.stats.mediaFailed++; - log(`Failed to download media ${item.filename}: ${err.message}`, 'warn'); + log(`Failed to download ${item.isVideo ? 'video' : 'photo'} ${item.filename}: ${err.message}`, 'warn'); } updateMetrics(); @@ -643,7 +855,7 @@ async function fetchMediaBlob(url) { let retries = 0; while (retries < 3) { try { - const res = await fetch(url); + const res = await fetch(url, { credentials: 'omit' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.blob(); } catch (err) { @@ -764,26 +976,31 @@ function generateCSV(activities) { 'Activity ID', 'Date & Time', 'Type', + 'Event Action / Summary', + 'Subtype', 'Title', 'Note / Caption', - 'Logged By', + 'Staff / Logged By', 'Media File Path', 'Food / Meal', 'Amount Eaten', 'Nap Start', 'Nap End', - 'Nap Mins', - 'Potty Type', - 'Milestone' + 'Nap Duration (Mins)', + 'Potty / Diaper Type', + 'Milestone', + 'Attendance / Health Info' ]; const rows = activities.map(a => [ escapeCSV(a.id), escapeCSV(a.created_at), escapeCSV(a.activity_type), - escapeCSV(a.title), - escapeCSV(a.note), - escapeCSV(a.teacher_name), + escapeCSV(a.action_summary || ''), + escapeCSV(a.action_subtype || ''), + escapeCSV(a.title || ''), + escapeCSV(a.note || ''), + escapeCSV(a.teacher_name || ''), escapeCSV(a.media_local_path || a.video_url || a.image_url || ''), escapeCSV(a.food_type || ''), escapeCSV(a.amount_eaten || ''), @@ -791,7 +1008,8 @@ function generateCSV(activities) { escapeCSV(a.nap_end || ''), escapeCSV(a.duration_minutes || ''), escapeCSV(a.potty_type || ''), - escapeCSV(a.milestone_name || '') + escapeCSV(a.milestone_name || ''), + escapeCSV(a.attendance_info || '') ]); return [headers.join(','), ...rows.map(r => r.join(','))].join('\r\n'); @@ -810,3 +1028,4 @@ function sanitizeFilename(str) { function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } + diff --git a/dashboard/viewer_template.html b/dashboard/viewer_template.html index 59fd731..d6656d7 100644 --- a/dashboard/viewer_template.html +++ b/dashboard/viewer_template.html @@ -358,7 +358,7 @@ }) : ''; const mediaPath = act.media_local_path || act.video_url || act.image_url; - const isVideo = (act.video_url || mediaPath?.endsWith('.mp4') || act.activity_type === 'video'); + const isVideo = (act.video_url || mediaPath?.endsWith('.mp4') || act.activity_type === 'ac_video' || act.activity_type === 'video'); let mediaHtml = ''; if (mediaPath) { @@ -374,7 +374,7 @@ detailLines.push(`🍽️ ${act.food_type || 'Meal'}: ${act.amount_eaten || 'Eaten'}`); } if (act.nap_start || act.nap_end || act.duration_minutes) { - detailLines.push(`😴 Nap Duration: ${act.duration_minutes ? act.duration_minutes + ' mins' : (act.nap_start || '') + ' - ' + (act.nap_end || '')}`); + detailLines.push(`😴 Nap: ${act.duration_minutes ? act.duration_minutes + ' mins' : (act.nap_start || '') + ' - ' + (act.nap_end || '')}`); } if (act.potty_type) { detailLines.push(`🚽 Potty: ${act.potty_type}`); @@ -382,16 +382,21 @@ if (act.milestone_name) { detailLines.push(`🌟 Milestone: ${act.milestone_name}`); } + if (act.attendance_info) { + detailLines.push(`📋 Attendance: ${act.attendance_info}`); + } + + const displayTitle = act.action_summary || act.title || act.activity_type; return `
${mediaHtml}
- ${act.activity_type || type} + ${act.action_subtype || act.activity_type || type} ${formattedDate}
- ${act.title ? `
${act.title}
` : ''} +
${displayTitle}
${act.note ? `
${act.note}
` : ''} ${detailLines.length > 0 ? `
${detailLines.join('
')}
` : ''}