Compare commits

...
3 Commits
Author SHA1 Message Date
haopengzhan ae39f52563 feat: implement robust student ID discovery and multi-endpoint data extraction for activities
CI Verification / Lint & Extension Integrity Check (push) Successful in 18s
2026-08-29 22:39:58 -07:00
haopengzhan 0e23532113 feat: add python analysis scripts and HTML reporting templates for nap and attendance data
CI Verification / Lint & Extension Integrity Check (push) Successful in 2m58s
2026-08-23 22:28:01 -07:00
haopengzhan fb73590c3e refactor: update CI workflow to use job outputs and correct developer name in documentation 2026-08-23 15:39:13 -07:00
14 changed files with 3999 additions and 186 deletions
+6 -4
View File
@@ -29,12 +29,13 @@ jobs:
id: version
run: |
VERSION=$(node -p "require('./manifest.json').version")
echo "EXT_VERSION=$VERSION" >> $GITHUB_ENV
echo "ext_version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Building extension release for version: $VERSION"
- name: Build Production Extension ZIP
id: package
run: |
ZIP_NAME="brightwheel-exporter-v${{ env.EXT_VERSION }}.zip"
ZIP_NAME="brightwheel-exporter-v${{ steps.version.outputs.ext_version }}.zip"
echo "📦 Packaging $ZIP_NAME..."
zip -r "$ZIP_NAME" . \
-x "*.git*" \
@@ -49,13 +50,14 @@ jobs:
-x "LICENSE" \
-x "*.zip"
echo "ZIP_FILE=$ZIP_NAME" >> $GITHUB_ENV
echo "zip_file=$ZIP_NAME" >> "$GITHUB_OUTPUT"
echo "✅ Package contents verified:"
unzip -l "$ZIP_NAME"
- name: Upload ZIP to GitHub Release Assets
uses: softprops/action-gh-release@v2
with:
files: ${{ env.ZIP_FILE }}
files: ${{ steps.package.outputs.zip_file }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -4,7 +4,7 @@ Thumbs.db
*.tmp
# User Export Data & Backups (Prevent leaking child photos/videos/records)
emmy/
emmy*/
exports/
data/
test_data/
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Haopeng Zhan
Copyright (c) 2026 Pengzhan Hao
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+15 -2
View File
@@ -10,7 +10,7 @@ DIST_DIR := dist
ZIP_NAME := brightwheel-exporter-v$(VERSION).zip
ZIP_PATH := $(DIST_DIR)/$(ZIP_NAME)
.PHONY: all build zip check clean help
.PHONY: all build zip check clean help analyze-naps analyze-attendance
all: build
@@ -19,8 +19,20 @@ help:
@echo "Available commands:"
@echo " make build (or make zip) - Build the Chrome Web Store production ZIP package"
@echo " make check - Verify JavaScript syntax and manifest validity"
@echo " make analyze-naps - Run nap analysis (e.g. make analyze-naps INPUT=path/to/export)"
@echo " make analyze-attendance - Run attendance analysis (e.g. make analyze-attendance INPUT=path/to/export)"
@echo " make clean - Remove the dist directory and build artifacts"
## analyze-naps: Run the nap & sleep data analyzer on any exported dataset
INPUT ?= exports/
OUTDIR ?= $(INPUT)
analyze-naps:
@python3 tools/analyze_naps.py --input $(INPUT) --outdir $(OUTDIR)
## analyze-attendance: Run the attendance & daily schedule analyzer on any exported dataset
analyze-attendance:
@python3 tools/analyze_attendance.py --input $(INPUT) --outdir $(OUTDIR)
## check: Verify JavaScript syntax and manifest validity
check:
@echo "🔍 Validating manifest.json and JavaScript files..."
@@ -40,10 +52,11 @@ build: check
-x ".githooks/*" \
-x ".github/*" \
-x "$(DIST_DIR)/*" \
-x "emmy/*" \
-x "tools/*" \
-x "exports/*" \
-x "data/*" \
-x "test_data/*" \
-x "*_export/*" \
-x "screenshots/*" \
-x "Makefile" \
-x "*.md" \
+2 -1
View File
@@ -1,7 +1,7 @@
# Privacy Policy for Brightwheel Memories & Reports Exporter
**Last Updated:** August 23, 2026
**Developer:** Haopeng Zhan
**Developer:** Pengzhan Hao
**Repository:** [https://git.pengzhan.dev/haopengzhan/brightwheel-exporter](https://git.pengzhan.dev/haopengzhan/brightwheel-exporter)
---
@@ -39,5 +39,6 @@ This extension interacts only with Brightwheel's web application to help users b
## 5. Contact & Open Source Verification
Because this project is open source under the MIT License, you can inspect the full source code and verify our privacy practices directly in the Git repository:
- **Git Repository:** [https://git.pengzhan.dev/haopengzhan/brightwheel-exporter](https://git.pengzhan.dev/haopengzhan/brightwheel-exporter)
- **Report Issues / Inquiries:** [https://git.pengzhan.dev/haopengzhan/brightwheel-exporter/issues](https://git.pengzhan.dev/haopengzhan/brightwheel-exporter/issues)
+2 -1
View File
@@ -63,7 +63,7 @@
<details class="manual-id-details" id="manualDetails">
<summary>Enter Student UUID Manually</summary>
<div class="manual-input-row">
<input type="text" id="manualStudentId" placeholder="e.g. edda433b-2a24-4da7-bbd5-9f131b4b2565" value="edda433b-2a24-4da7-bbd5-9f131b4b2565">
<input type="text" id="manualStudentId" placeholder="Auto-detected from active Brightwheel tab, or enter UUID">
<button id="setManualIdBtn" class="secondary-btn">Use ID</button>
</div>
</details>
@@ -291,6 +291,7 @@
<div class="card-header log-header">
<h3>Live Activity Log</h3>
<div class="log-actions">
<button id="inspectFeedBtn" class="link-btn" style="color: var(--primary); font-weight: 600; margin-right: 8px;">🔬 Inspect Live Tab</button>
<button id="clearLogBtn" class="link-btn">Clear</button>
</div>
</div>
+194 -78
View File
@@ -5,7 +5,7 @@
// Application State
const state = {
students: [],
selectedStudentId: 'edda433b-2a24-4da7-bbd5-9f131b4b2565',
selectedStudentId: '',
selectedStudentData: null,
destinationDirectoryHandle: null,
isExporting: false,
@@ -78,7 +78,8 @@ function initDOMElements() {
metricSize: document.getElementById('metricSize'),
metricRateLimit: document.getElementById('metricRateLimit'),
terminalLog: document.getElementById('terminalLog'),
clearLogBtn: document.getElementById('clearLogBtn')
clearLogBtn: document.getElementById('clearLogBtn'),
inspectFeedBtn: document.getElementById('inspectFeedBtn')
};
}
@@ -104,65 +105,176 @@ function log(message, type = 'info') {
// Session & Student Discovery
// -------------------------------------------------------------
function extractStudentsFromPayload(data) {
if (!data) return [];
const students = [];
const seenIds = new Set();
function addStudent(s) {
if (!s) return;
const sId = s.id || s.object_id || s.student_id;
if (sId && !seenIds.has(sId)) {
seenIds.add(sId);
students.push({
id: sId,
first_name: s.first_name || s.name?.split(' ')?.[0] || 'Child',
last_name: s.last_name || s.name?.split(' ')?.slice(1).join(' ') || '',
picture_url: s.picture_url || s.avatar_url || s.photo_url || s.profile_photo?.image_url || s.profile_photo?.thumbnail_url || null,
school_name: s.school_name || s.school?.name || s.room_name || s.room?.name || 'Brightwheel Account',
room: s.room || null
});
}
}
if (Array.isArray(data.students)) data.students.forEach(addStudent);
if (Array.isArray(data.children)) data.children.forEach(addStudent);
if (Array.isArray(data.profiles)) data.profiles.forEach(addStudent);
if (Array.isArray(data.guardians)) {
data.guardians.forEach(g => {
if (Array.isArray(g.students)) g.students.forEach(addStudent);
if (Array.isArray(g.children)) g.children.forEach(addStudent);
});
}
if (data.student) addStudent(data.student);
if (data.user?.students) data.user.students.forEach(addStudent);
if (Array.isArray(data.activities) || Array.isArray(data)) {
const items = Array.isArray(data.activities) ? data.activities : data;
for (const item of items) {
if (item.target && (item.target.user_type === 'student' || item.target.object_id)) {
addStudent(item.target);
}
}
}
return students;
}
async function checkSessionAndDiscoverStudents() {
log('Checking active Brightwheel browser session...', 'info');
// Check stored active student ID first
const stored = await chrome.storage.local.get(['lastActiveStudentId']);
if (stored.lastActiveStudentId) {
state.selectedStudentId = stored.lastActiveStudentId;
el.manualStudentId.value = stored.lastActiveStudentId;
}
// 1. Try communicating with active Brightwheel tab first (most accurate)
try {
// 1. Try to fetch user/guardian profile to list students
const userRes = await fetchBrightwheelAPI('https://schools.mybrightwheel.com/api/v1/users/me');
const [bwTab] = await chrome.tabs.query({ url: 'https://schools.mybrightwheel.com/*' });
if (bwTab && bwTab.id) {
const tabRes = await new Promise(resolve => {
chrome.tabs.sendMessage(bwTab.id, { action: 'GET_STUDENTS_AND_SESSION' }, res => {
resolve(chrome.runtime.lastError ? null : res);
});
});
if (tabRes && tabRes.success && tabRes.students && tabRes.students.length > 0) {
el.sessionDot.classList.add('online');
el.sessionStatusText.textContent = 'Brightwheel Tab Connected';
state.students = tabRes.students;
if (tabRes.activeStudentId) {
state.selectedStudentId = tabRes.activeStudentId;
} else if (!state.selectedStudentId) {
state.selectedStudentId = tabRes.students[0].id;
}
state.selectedStudentData = state.students.find(s => s.id === state.selectedStudentId) || state.students[0];
el.manualStudentId.value = state.selectedStudentId;
renderStudentsList();
log(`Discovered ${state.students.length} student profile(s) from live Brightwheel session.`, 'success');
return;
}
}
} catch (err) {
// Continue to direct API discovery
}
// 2. Direct API discovery from extension context
try {
const userRes = await fetchBrightwheelAPI('https://schools.mybrightwheel.com/api/v1/users/me');
if (userRes && userRes.success && userRes.data) {
el.sessionDot.classList.add('online');
el.sessionStatusText.textContent = 'Brightwheel Session Active';
log('Successfully connected to Brightwheel user profile.', 'success');
const userData = userRes.data;
const studentsFound = userData.students || userData.children || (userData.guardians && userData.guardians[0]?.students) || [];
if (studentsFound.length > 0) {
state.students = studentsFound;
const found = extractStudentsFromPayload(userRes.data);
if (found.length > 0) {
state.students = found;
if (!state.selectedStudentId) state.selectedStudentId = found[0].id;
state.selectedStudentData = found.find(s => s.id === state.selectedStudentId) || found[0];
el.manualStudentId.value = state.selectedStudentId;
renderStudentsList();
log(`Loaded ${found.length} student profile(s) from user account.`, 'success');
return;
}
}
// 2. Try fetching specific student profile if selected
const guardianRes = await fetchBrightwheelAPI('https://schools.mybrightwheel.com/api/v1/guardians/me');
if (guardianRes && guardianRes.success && guardianRes.data) {
el.sessionDot.classList.add('online');
el.sessionStatusText.textContent = 'Brightwheel Session Active';
const found = extractStudentsFromPayload(guardianRes.data);
if (found.length > 0) {
state.students = found;
if (!state.selectedStudentId) state.selectedStudentId = found[0].id;
state.selectedStudentData = found.find(s => s.id === state.selectedStudentId) || found[0];
el.manualStudentId.value = state.selectedStudentId;
renderStudentsList();
log(`Loaded ${found.length} student profile(s) from guardian account.`, 'success');
return;
}
}
// Probe recent activities to extract student target from live feed
const targetId = state.selectedStudentId;
const actProbe = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${targetId}/activities?page_size=10`);
if (actProbe && actProbe.success && actProbe.data) {
const found = extractStudentsFromPayload(actProbe.data);
if (found.length > 0) {
el.sessionDot.classList.add('online');
el.sessionStatusText.textContent = 'Brightwheel Session Active';
state.students = found;
if (!state.selectedStudentId) state.selectedStudentId = found[0].id;
state.selectedStudentData = found.find(s => s.id === state.selectedStudentId) || found[0];
el.manualStudentId.value = state.selectedStudentId;
renderStudentsList();
log(`Detected active student: ${state.selectedStudentData.first_name || 'Student'} (${state.selectedStudentId})`, 'success');
return;
}
}
// 3. Fallback: If student ID stored or entered manually
if (state.selectedStudentId) {
const studentRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${state.selectedStudentId}`);
if (studentRes && studentRes.success && studentRes.data) {
el.sessionDot.classList.add('online');
el.sessionStatusText.textContent = 'Brightwheel Session Active';
const s = studentRes.data.student || studentRes.data;
state.students = [s];
state.selectedStudentData = s;
renderStudentsList();
log(`Loaded student profile: ${s.first_name || 'Student'} (${state.selectedStudentId})`, 'success');
log(`Loaded profile for student: ${s.first_name || 'Student'} (${state.selectedStudentId})`, 'success');
return;
}
}
// Fallback: Default student card with manual ID
el.sessionDot.classList.add('online');
el.sessionStatusText.textContent = 'Session Connected';
state.students = [{
id: state.selectedStudentId,
first_name: 'Student',
last_name: `(${state.selectedStudentId.substring(0, 8)}...)`,
school_name: 'Brightwheel School'
school_name: 'Brightwheel Account'
}];
renderStudentsList();
log(`Using student UUID: ${state.selectedStudentId}`, 'info');
return;
}
// No student found yet
el.sessionStatusText.textContent = 'Open Brightwheel in another tab to auto-connect';
renderStudentsList();
} catch (err) {
log(`Session check note: ${err.message}. Ready with manual ID.`, 'warn');
log(`Session check note: ${err.message}.`, 'warn');
renderStudentsList();
}
}
@@ -192,7 +304,7 @@ function renderStudentsList() {
el.studentsList.innerHTML = '';
if (state.students.length === 0) {
el.studentsList.innerHTML = '<div class="loading-placeholder">No students auto-detected. Use manual UUID below.</div>';
el.studentsList.innerHTML = '<div class="loading-placeholder">No students auto-detected. Please ensure Brightwheel is open in a tab or enter UUID below.</div>';
return;
}
@@ -202,7 +314,7 @@ function renderStudentsList() {
const firstName = student.first_name || 'My';
const lastName = student.last_name || 'Child';
const initials = (firstName[0] || 'B') + (lastName[0] || 'W');
const school = student.school_name || student.room_name || 'Brightwheel Account';
const school = student.school_name || student.room_name || student.room?.name || 'Brightwheel Account';
const card = document.createElement('div');
card.className = `student-card ${isSelected ? 'selected' : ''}`;
@@ -227,6 +339,7 @@ function renderStudentsList() {
state.selectedStudentId = sId;
state.selectedStudentData = student;
el.manualStudentId.value = sId;
chrome.storage.local.set({ lastActiveStudentId: sId });
log(`Selected child: ${firstName} ${lastName} (${sId})`, 'info');
});
@@ -239,11 +352,8 @@ function renderStudentsList() {
// -------------------------------------------------------------
function initDatePresets() {
const today = new Date();
const currentYear = today.getFullYear();
el.startDate.value = '';
el.endDate.value = today.toISOString().split('T')[0];
el.endDate.value = '';
}
function setDateRange(rangeType) {
@@ -252,7 +362,7 @@ function setDateRange(rangeType) {
if (rangeType === 'all') {
el.startDate.value = '';
el.endDate.value = todayStr;
el.endDate.value = '';
} else if (rangeType === 'this-year') {
el.startDate.value = `${today.getFullYear()}-01-01`;
el.endDate.value = todayStr;
@@ -387,45 +497,30 @@ async function handleInspectFeed() {
return;
}
log(`✅ Live Inspection Complete for: ${res.currentUrl}`, 'success');
log(`✅ Live Tab Diagnostic Complete: ${res.currentUrl}`, 'success');
log(`Page Title: "${res.pageTitle}"`, 'info');
log(`DOM Cards Detected on Page: ${res.domCards?.length || 0}`, 'info');
log(`DOM Cards: ${res.domCards?.length || 0} | React Fiber Activities: ${res.reactFiberCount || 0}`, 'info');
if (res.domCards && res.domCards.length > 0) {
res.domCards.slice(0, 3).forEach((dc, i) => {
log(` [DOM Card ${i + 1}] Text: "${dc.raw_text.replace(/\s+/g, ' ').substring(0, 100)}..."`, 'info');
});
if (res.endpointProbes) {
for (const [name, probe] of Object.entries(res.endpointProbes)) {
if (probe.error) {
log(`[Probe: ${name}] Error: ${probe.error}`, 'error');
} else {
log(`[Probe: ${name}] Status ${probe.status} | Items: ${probe.itemCount} | Latest Date: ${probe.latestDate || 'none'}`, probe.itemCount > 0 ? 'success' : 'warn');
}
const acts = res.apiSampleActivities || [];
log(`API Sample Activities Received: ${acts.length}`, 'success');
acts.forEach((a, idx) => {
const type = a.activity_type || a.action_type || a.type;
const presentKeys = Object.keys(a).filter(k => a[k] !== null && a[k] !== undefined && a[k] !== '');
log(`[API Item ${idx + 1}] Type="${type}", Note="${a.note || a.body || ''}", Keys=[${presentKeys.join(', ')}]`, 'info');
if (type === 'ac_video' || type === 'video') {
log(` -> Video payload: media=${JSON.stringify(a.media || null)}, video_url=${a.video_url || null}`, 'warn');
}
if (type === 'ac_nap' || type === 'nap') {
log(` -> Nap payload: action_type=${a.action_type}, nap_start=${a.nap_start || a.start_time}, nap_end=${a.nap_end || a.end_time}, duration=${a.duration || a.duration_minutes}`, 'warn');
}
if (type === 'ac_checkin' || type === 'checkin') {
log(` -> Checkin payload: action_type=${a.action_type}, signed_in_by=${a.signed_in_by || a.actor_name || a.guardian_name}, event_type=${a.event_type}`, 'warn');
}
});
// Automatically download inspection JSON report
const blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `brightwheel_inspection_${state.selectedStudentId}_${Date.now()}.json`;
a.download = `brightwheel_inspection_${state.selectedStudentId || 'active'}_${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
log(`📁 Downloaded full inspection report to your Downloads folder (${(blob.size/1024).toFixed(1)} KB).`, 'success');
log(`📁 Downloaded full diagnostic inspection JSON report (${(blob.size / 1024).toFixed(1)} KB).`, 'success');
});
} catch (e) {
log('Inspection error: ' + e.message, 'error');
@@ -555,12 +650,11 @@ function updateMetrics() {
async function fetchAllActivities(studentId) {
const pageSize = 50;
let page = 1;
let hasMore = true;
const startDate = el.startDate.value;
const endDate = el.endDate.value;
const startDate = el.startDate.value?.trim();
const endDate = el.endDate.value?.trim();
const pacingDelay = parseInt(el.concurrencySelect.value) || 200;
const seenTypes = new Set();
const seenActivityIds = new Set();
updateProgress(5, 'Harvesting activity logs...');
@@ -578,18 +672,21 @@ async function fetchAllActivities(studentId) {
// Non-critical
}
// Harvester for a specific Brightwheel feed endpoint (0-indexed pagination)
async function harvestEndpoint(endpointBaseUrl, label) {
let page = 0;
let hasMore = true;
log(`Harvesting from ${label}...`, 'info');
while (hasMore && !state.isCancelled) {
while (state.isPaused && !state.isCancelled) {
await sleep(500);
}
if (state.isCancelled) break;
// Build URL query
let url = `https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities?page_size=${pageSize}&page=${page}`;
if (startDate) url += `&start_date=${startDate}`;
if (endDate) url += `&end_date=${endDate}`;
log(`Fetching activities page ${page}...`, 'info');
let url = `${endpointBaseUrl}${endpointBaseUrl.includes('?') ? '&' : '?'}page_size=${pageSize}&page=${page}`;
if (startDate) url += `&start_date=${encodeURIComponent(startDate)}`;
if (endDate) url += `&end_date=${encodeURIComponent(endDate)}`;
let response = null;
let retries = 0;
@@ -610,27 +707,21 @@ async function fetchAllActivities(studentId) {
}
}
if (!response || !response.success || !response.data) {
if (page === 1) {
log('Trying alternate activity feed endpoint...', 'info');
const altUrl = `https://schools.mybrightwheel.com/api/v1/activities?student_ids[]=${studentId}&page_size=${pageSize}&page=${page}`;
const altRes = await fetchBrightwheelAPI(altUrl);
if (altRes && altRes.success && altRes.data) {
response = altRes;
}
}
}
const rawActivities = response?.data?.activities || response?.data?.items || response?.data || [];
const rawActivities = response?.data?.activities || response?.data?.items || (Array.isArray(response?.data) ? response.data : []);
if (!Array.isArray(rawActivities) || rawActivities.length === 0) {
log(`No more activities found after page ${page - 1}. Done harvesting.`, 'info');
hasMore = false;
break;
}
// Filter and normalize activities
for (const act of rawActivities) {
const actId = act.object_id || act.id;
if (actId && seenActivityIds.has(actId)) {
continue;
}
if (actId) {
seenActivityIds.add(actId);
}
// Schema inspection for debugging
const actType = act.activity_type || act.action_type || act.type || 'unknown';
if (!seenTypes.has(actType)) {
@@ -640,13 +731,16 @@ async function fetchAllActivities(studentId) {
}
// If video activity has no immediate URL, fetch single activity detail
if ((actType === 'ac_video' || actType === 'video') && !act.video_url && !act.media?.video_url) {
if ((actType === 'ac_video' || actType === 'video') && !act.video_url && !act.video_info && !act.media?.video_url) {
try {
const detailRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities/${act.id}`);
const sid = act.target?.object_id || studentId;
if (sid && act.id) {
const detailRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${sid}/activities/${act.id}`);
if (detailRes && detailRes.success && detailRes.data) {
const detailObj = detailRes.data.activity || detailRes.data;
Object.assign(act, detailObj);
}
}
} catch (e) {
// Continue
}
@@ -659,7 +753,7 @@ async function fetchAllActivities(studentId) {
state.stats.activitiesCount = state.activities.length;
updateMetrics();
updateProgress(Math.min(30, 5 + page * 2), `Harvested ${state.activities.length} activities (Page ${page})`);
updateProgress(Math.min(30, 5 + (state.activities.length / 50) * 2), `Harvested ${state.activities.length} activities (Page ${page + 1})`);
if (rawActivities.length < pageSize) {
hasMore = false;
@@ -671,6 +765,28 @@ async function fetchAllActivities(studentId) {
}
}
// 1. Primary: Student-scoped activity feed
if (studentId) {
await harvestEndpoint(`https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities`, `Student Activity Feed (${studentId.substring(0, 8)}...)`);
} else if (state.students.length > 0) {
for (const st of state.students) {
if (st.id) {
await harvestEndpoint(`https://schools.mybrightwheel.com/api/v1/students/${st.id}/activities`, `Student Activity Feed (${st.first_name || st.id})`);
}
}
}
// Sort activities chronologically descending
state.activities.sort((a, b) => {
const da = new Date(a.created_at || a.event_date || 0).getTime();
const db = new Date(b.created_at || b.event_date || 0).getTime();
return db - da;
});
state.stats.activitiesCount = state.activities.length;
updateMetrics();
}
function matchesSelectedCategories(act) {
const type = (act.activity_type || act.action_type || act.type || '').toLowerCase();
+7
View File
@@ -287,6 +287,13 @@
return;
}
// Sort activities descending by date (newest first)
ARCHIVE_DATA.activities.sort((a, b) => {
const da = new Date(a.created_at || a.event_date || 0).getTime();
const db = new Date(b.created_at || b.event_date || 0).getTime();
return db - da;
});
renderProfile();
renderFeed('all', '');
setupEvents();
+2 -1
View File
@@ -30,7 +30,8 @@
"permissions": [
"storage",
"downloads",
"tabs"
"tabs",
"cookies"
],
"host_permissions": [
"https://schools.mybrightwheel.com/*",
+267 -25
View File
@@ -6,13 +6,55 @@
(function () {
console.log('[Brightwheel Exporter] Content script initialized.');
// Extract student ID from URL if present
// Extract student ID from URL, DOM, or Storage
function detectCurrentStudentId() {
const match = window.location.pathname.match(/\/students\/([a-zA-Z0-9-]+)/);
if (match && match[1]) {
chrome.storage.local.set({ lastActiveStudentId: match[1] });
return match[1];
// 1. Check URL pathname & search params
const pathMatch = window.location.pathname.match(/\/(?:students|children|profiles)\/([a-zA-Z0-9-]+)/);
if (pathMatch && pathMatch[1]) {
chrome.storage.local.set({ lastActiveStudentId: pathMatch[1] });
return pathMatch[1];
}
const urlParams = new URLSearchParams(window.location.search);
const paramId = urlParams.get('student_id') || urlParams.get('studentId') || urlParams.get('child_id');
if (paramId) {
chrome.storage.local.set({ lastActiveStudentId: paramId });
return paramId;
}
// 2. Check DOM links or attributes
const studentLink = document.querySelector('a[href*="/students/"], a[href*="/children/"], [data-student-id]');
if (studentLink) {
const href = studentLink.getAttribute('href') || '';
const hrefMatch = href.match(/\/(?:students|children)\/([a-zA-Z0-9-]+)/);
if (hrefMatch && hrefMatch[1]) {
chrome.storage.local.set({ lastActiveStudentId: hrefMatch[1] });
return hrefMatch[1];
}
const dataId = studentLink.getAttribute('data-student-id');
if (dataId) {
chrome.storage.local.set({ lastActiveStudentId: dataId });
return dataId;
}
}
// 3. Check localStorage for student UUID
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const val = localStorage.getItem(key);
if (typeof val === 'string' && (key.includes('student') || key.includes('child') || key.includes('user') || key.includes('auth'))) {
const uuidMatch = val.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
if (uuidMatch && uuidMatch[0]) {
chrome.storage.local.set({ lastActiveStudentId: uuidMatch[0] });
return uuidMatch[0];
}
}
}
} catch (e) {
// Storage access blocked or restricted
}
return null;
}
@@ -28,6 +70,118 @@
}
}).observe(document, { subtree: true, childList: true });
// Deep extract student profiles from API payloads
function extractStudentsFromData(data) {
if (!data) return [];
const students = [];
const seenIds = new Set();
function addStudent(s) {
if (!s) return;
const sId = s.id || s.object_id || s.student_id;
if (sId && !seenIds.has(sId)) {
seenIds.add(sId);
students.push({
id: sId,
first_name: s.first_name || s.name?.split(' ')?.[0] || 'Child',
last_name: s.last_name || s.name?.split(' ')?.slice(1).join(' ') || '',
picture_url: s.picture_url || s.avatar_url || s.photo_url || s.profile_photo?.image_url || s.profile_photo?.thumbnail_url || null,
school_name: s.school_name || s.school?.name || s.room_name || s.room?.name || 'Brightwheel Profile',
room: s.room || null
});
}
}
if (Array.isArray(data.students)) data.students.forEach(addStudent);
if (Array.isArray(data.children)) data.children.forEach(addStudent);
if (Array.isArray(data.profiles)) data.profiles.forEach(addStudent);
if (Array.isArray(data.guardians)) {
data.guardians.forEach(g => {
if (Array.isArray(g.students)) g.students.forEach(addStudent);
if (Array.isArray(g.children)) g.children.forEach(addStudent);
});
}
if (data.student) addStudent(data.student);
if (data.user?.students) data.user.students.forEach(addStudent);
if (Array.isArray(data.activities) || Array.isArray(data)) {
const items = Array.isArray(data.activities) ? data.activities : data;
for (const item of items) {
if (item.target && item.target.user_type === 'student') {
addStudent(item.target);
}
}
}
return students;
}
// Fetch full student & session discovery directly within authenticated tab
async function discoverSessionAndStudents() {
const results = {
authenticated: false,
user: null,
students: [],
activeStudentId: detectCurrentStudentId(),
liveActivitiesSample: []
};
try {
const meRes = await fetch('https://schools.mybrightwheel.com/api/v1/users/me', {
credentials: 'include',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
}).then(r => r.ok ? r.json() : null).catch(() => null);
if (meRes) {
results.authenticated = true;
results.user = meRes;
const found = extractStudentsFromData(meRes);
found.forEach(s => results.students.push(s));
}
const guardianRes = await fetch('https://schools.mybrightwheel.com/api/v1/guardians/me', {
credentials: 'include',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
}).then(r => r.ok ? r.json() : null).catch(() => null);
if (guardianRes) {
results.authenticated = true;
const found = extractStudentsFromData(guardianRes);
found.forEach(s => {
if (!results.students.some(existing => existing.id === s.id)) {
results.students.push(s);
}
});
}
const actProbe = await fetch('https://schools.mybrightwheel.com/api/v1/activities?page_size=10', {
credentials: 'include',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
}).then(r => r.ok ? r.json() : null).catch(() => null);
if (actProbe) {
results.authenticated = true;
const acts = actProbe.activities || actProbe.items || [];
results.liveActivitiesSample = acts.slice(0, 5);
const fromActs = extractStudentsFromData(acts);
fromActs.forEach(s => {
if (!results.students.some(existing => existing.id === s.id)) {
results.students.push(s);
}
});
}
if (results.students.length > 0 && !results.activeStudentId) {
results.activeStudentId = results.students[0].id;
chrome.storage.local.set({ lastActiveStudentId: results.activeStudentId });
}
} catch (e) {
console.warn('[Brightwheel Exporter] Discovery note:', e);
}
return results;
}
// Listen for messages from extension popup or dashboard
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'GET_PAGE_CONTEXT') {
@@ -40,49 +194,137 @@
return true;
}
if (message.action === 'GET_STUDENTS_AND_SESSION') {
discoverSessionAndStudents().then(res => {
sendResponse({ success: true, ...res });
}).catch(err => {
sendResponse({ success: false, error: err.message });
});
return true;
}
if (message.action === 'SCRAPE_DOM_FEED') {
const cards = scrapeDomFeedCards();
sendResponse({
success: true,
activities: cards,
count: cards.length
});
return true;
}
if (message.action === 'INSPECT_LIVE_FEED') {
(async () => {
try {
const studentId = message.studentId || detectCurrentStudentId();
const apiListRes = await fetch(`https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities?page_size=25`, {
credentials: 'include',
headers: { 'Accept': 'application/json' }
}).then(r => r.json()).catch(e => ({ error: e.message }));
const endpointProbes = {};
const rawActs = apiListRes?.activities || apiListRes?.items || [];
// Sample detail fetches for video, nap, checkin
const sampleDetails = {};
for (const act of rawActs.slice(0, 10)) {
const type = act.activity_type || act.action_type || act.type;
if (!sampleDetails[type] && act.id) {
const detail = await fetch(`https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities/${act.id}`, {
async function probe(name, url) {
try {
const res = await fetch(url, {
credentials: 'include',
headers: { 'Accept': 'application/json' }
}).then(r => r.json()).catch(e => ({ error: e.message }));
sampleDetails[type] = detail;
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
});
const json = await res.json().catch(() => null);
const items = json?.activities || json?.items || (Array.isArray(json) ? json : []);
endpointProbes[name] = {
url,
status: res.status,
ok: res.ok,
itemCount: items.length,
latestDate: items[0]?.event_date || items[0]?.created_at || null,
earliestDate: items[items.length - 1]?.event_date || items[items.length - 1]?.created_at || null,
sampleItems: items.slice(0, 5),
rawSample: json
};
} catch (e) {
endpointProbes[name] = { url, error: e.message };
}
}
// 1. Probe general activities endpoint
await probe('parent_activities_feed', 'https://schools.mybrightwheel.com/api/v1/activities?page_size=25');
// 2. Probe student-specific endpoint
if (studentId) {
await probe('student_activities_feed', `https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities?page_size=25`);
}
// 3. Probe user/guardian profiles
await probe('users_me', 'https://schools.mybrightwheel.com/api/v1/users/me');
await probe('guardians_me', 'https://schools.mybrightwheel.com/api/v1/guardians/me');
// 4. Probe feed / timeline
await probe('feed_endpoint', 'https://schools.mybrightwheel.com/api/v1/feed?page_size=25');
const domCards = scrapeDomFeedCards();
const reactFiberActivities = extractActivitiesFromReactFiber();
sendResponse({
success: true,
studentId,
apiSampleActivities: rawActs.slice(0, 10),
sampleDetails,
domCards: domCards.slice(0, 20),
currentUrl: window.location.href,
pageTitle: document.title,
currentUrl: window.location.href
endpointProbes,
domCards: domCards.slice(0, 20),
reactFiberCount: reactFiberActivities.length,
reactFiberSample: reactFiberActivities.slice(0, 5)
});
} catch (err) {
sendResponse({ success: false, error: err.message });
}
})();
return true; // async
return true;
}
});
// Deep React Fiber state extractor for rendered activities
function extractActivitiesFromReactFiber() {
const rawItems = [];
const seenIds = new Set();
function inspectProps(props) {
if (!props || typeof props !== 'object') return;
const candidate = props.activity || props.item || props.feedItem || props.record || props.event;
if (candidate && typeof candidate === 'object') {
const id = candidate.object_id || candidate.id;
if (id && !seenIds.has(id)) {
seenIds.add(id);
rawItems.push(candidate);
}
}
if (Array.isArray(props.activities)) {
props.activities.forEach(a => {
const id = a?.object_id || a?.id;
if (id && !seenIds.has(id)) {
seenIds.add(id);
rawItems.push(a);
}
});
}
}
try {
document.querySelectorAll('*').forEach(node => {
const fiberKey = Object.keys(node).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'));
if (fiberKey && node[fiberKey]) {
let fiber = node[fiberKey];
let depth = 0;
while (fiber && depth < 10) {
inspectProps(fiber.memoizedProps);
inspectProps(fiber.pendingProps);
fiber = fiber.return;
depth++;
}
}
});
} catch (e) {
// Ignore
}
return rawItems;
}
// DOM Scraper for Brightwheel Web Feed
function scrapeDomFeedCards() {
const results = [];
+532
View File
@@ -0,0 +1,532 @@
#!/usr/bin/env python3
"""
Brightwheel Attendance & Daily Schedule Analyzer
Author: Pengzhan Hao
Description: Analyzes check-in and check-out records from Brightwheel export (activities.json or activities.csv),
computes daily stay durations, drop-off/pick-up distributions, parent duty splits,
and generates comprehensive reports (CSV, JSON, Markdown, and animated HTML showcase).
"""
import os
import sys
import json
import csv
import math
import argparse
from datetime import datetime, timezone, timedelta
from collections import defaultdict, Counter
import zoneinfo
def parse_iso_datetime(dt_str, tz):
"""Parse ISO datetime string and convert to target timezone."""
if not dt_str:
return None
try:
clean_str = dt_str.replace("Z", "+00:00")
dt = datetime.fromisoformat(clean_str)
return dt.astimezone(tz)
except Exception:
return None
def extract_attendance_events_from_json(json_path, tz):
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
activities = data.get("activities", []) if isinstance(data, dict) else data
student_info = data.get("student", {}) if isinstance(data, dict) else {}
events = []
real_name = None
for a in activities:
raw = a.get("raw", {}) or {}
if not real_name:
target = raw.get("target") or {}
fn = target.get("first_name")
if fn and fn.lower() not in ["student", "child", "test"]:
real_name = fn
act_type = str(a.get("activity_type", "")).lower()
subtype = str(a.get("action_subtype", "")).lower()
summary = str(a.get("action_summary", "")).lower()
if "checkin" in act_type or "signed" in subtype or "signed" in summary:
dt_str = raw.get("event_date") or a.get("created_at")
local_dt = parse_iso_datetime(dt_str, tz)
if not local_dt:
continue
actor = raw.get("actor") or {}
fn_act = (actor.get("first_name") or "").strip()
ln_act = (actor.get("last_name") or "").strip()
actor_name = f"{fn_act} {ln_act}".strip()
if not actor_name:
actor_name = (a.get("teacher_name") or "Parent / Staff").strip()
is_in = "signed_in" in subtype or "checked in" in summary or raw.get("state") == 1
is_out = "signed_out" in subtype or "checked out" in summary or raw.get("state") == 0
events.append({
"id": a.get("id"),
"local_dt": local_dt,
"date": local_dt.strftime("%Y-%m-%d"),
"subtype": subtype,
"is_in": is_in,
"is_out": is_out,
"actor": actor_name,
"note": (a.get("note") or raw.get("note") or "").strip()
})
if real_name and not student_info.get("first_name"):
student_info["first_name"] = real_name
elif real_name and student_info.get("first_name") == "Student":
student_info["first_name"] = real_name
return events, student_info
def extract_attendance_events_from_csv(csv_path, tz):
events = []
with open(csv_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for r in reader:
act_type = str(r.get("Type", "")).lower()
subtype = str(r.get("Subtype", "")).lower()
summary = str(r.get("Event Action / Summary", "")).lower()
att_info = str(r.get("Attendance / Health Info", "")).lower()
if "checkin" in act_type or "signed" in subtype or "signed" in summary or "signed by" in att_info:
dt_str = r.get("Date & Time")
local_dt = parse_iso_datetime(dt_str, tz)
if not local_dt:
continue
actor_name = (r.get("Staff / Logged By") or "Parent / Staff").strip()
is_in = "in" in subtype or "signed in" in summary or "checked in" in summary
is_out = "out" in subtype or "signed out" in summary or "checked out" in summary
events.append({
"id": r.get("Activity ID"),
"local_dt": local_dt,
"date": local_dt.strftime("%Y-%m-%d"),
"subtype": subtype,
"is_in": is_in,
"is_out": is_out,
"actor": actor_name,
"note": (r.get("Note / Caption") or "").strip()
})
return events, {}
def pair_daily_attendance(events):
"""Group events by day and determine first check-in, last check-out, and daily duration."""
events.sort(key=lambda x: x["local_dt"])
day_map = defaultdict(list)
for e in events:
day_map[e["date"]].append(e)
sessions = []
unpaired_days = 0
for day_str, day_evts in sorted(day_map.items()):
ins = [e for e in day_evts if e["is_in"]]
outs = [e for e in day_evts if e["is_out"]]
first_in = ins[0] if ins else None
last_out = outs[-1] if outs else None
if first_in and last_out and last_out["local_dt"] > first_in["local_dt"]:
dur_mins = round((last_out["local_dt"] - first_in["local_dt"]).total_seconds() / 60)
dur_hours = round(dur_mins / 60.0, 2)
# Sanity check: 0.5 hours to 14 hours
if 30 <= dur_mins <= 840:
in_dt = first_in["local_dt"]
out_dt = last_out["local_dt"]
in_dec = in_dt.hour + in_dt.minute / 60.0
out_dec = out_dt.hour + out_dt.minute / 60.0
sessions.append({
"id": first_in["id"],
"date": day_str,
"month": day_str[:7],
"year": in_dt.year,
"weekday": in_dt.strftime("%A"),
"weekday_short": in_dt.strftime("%a"),
"weekday_num": in_dt.weekday(),
"dropoff_datetime": in_dt.isoformat(),
"pickup_datetime": out_dt.isoformat(),
"dropoff_time_str": in_dt.strftime("%I:%M %p"),
"pickup_time_str": out_dt.strftime("%I:%M %p"),
"dropoff_dec_hour": round(in_dec, 3),
"pickup_dec_hour": round(out_dec, 3),
"dropoff_mins_midnight": in_dt.hour * 60 + in_dt.minute,
"pickup_mins_midnight": out_dt.hour * 60 + out_dt.minute,
"stay_duration_minutes": dur_mins,
"stay_duration_hours": dur_hours,
"dropoff_by": first_in["actor"],
"pickup_by": last_out["actor"],
"notes": first_in["note"] or last_out["note"] or ""
})
else:
unpaired_days += 1
else:
unpaired_days += 1
return sessions, len(day_map), unpaired_days
def compute_attendance_statistics(sessions, total_calendar_days_with_activity):
if not sessions:
return {}
n = len(sessions)
durations = [s["stay_duration_hours"] for s in sessions]
total_hours = round(sum(durations), 1)
mean_hours = round(sum(durations) / n, 2)
mean_mins = round((sum(durations) / n) * 60)
# Variance and Std Dev
var_hours = sum((d - mean_hours) ** 2 for d in durations) / (n - 1) if n > 1 else 0
std_hours = round(math.sqrt(var_hours), 2)
sorted_durs = sorted(durations)
median_hours = sorted_durs[n // 2]
p25_hours = sorted_durs[int(n * 0.25)]
p75_hours = sorted_durs[int(n * 0.75)]
min_hours = min(durations)
max_hours = max(durations)
# Drop-off and Pick-up Times
dropoff_mins = [s["dropoff_mins_midnight"] for s in sessions]
pickup_mins = [s["pickup_mins_midnight"] for s in sessions]
avg_dropoff_m = sum(dropoff_mins) / n
avg_pickup_m = sum(pickup_mins) / n
avg_dropoff_str = f"{int(avg_dropoff_m // 60):02d}:{int(avg_dropoff_m % 60):02d}"
avg_dropoff_12h = datetime.strptime(avg_dropoff_str, "%H:%M").strftime("%I:%M %p")
avg_pickup_str = f"{int(avg_pickup_m // 60):02d}:{int(avg_pickup_m % 60):02d}"
avg_pickup_12h = datetime.strptime(avg_pickup_str, "%H:%M").strftime("%I:%M %p")
# Caregiver / Signer Split
dropoff_actors = Counter([s["dropoff_by"] for s in sessions])
pickup_actors = Counter([s["pickup_by"] for s in sessions])
dropoff_split = [
{"name": name, "count": count, "pct": round((count / n) * 100, 1)}
for name, count in dropoff_actors.most_common()
]
pickup_split = [
{"name": name, "count": count, "pct": round((count / n) * 100, 1)}
for name, count in pickup_actors.most_common()
]
# Monthly Breakdown
monthly_data = defaultdict(list)
for s in sessions:
monthly_data[s["month"]].append(s)
monthly_stats = []
for m in sorted(monthly_data.keys()):
m_sessions = monthly_data[m]
m_durs = [x["stay_duration_hours"] for x in m_sessions]
m_tot = sum(m_durs)
m_avg = m_tot / len(m_durs)
monthly_stats.append({
"month": m,
"days_attended": len(m_durs),
"total_hours": round(m_tot, 1),
"avg_hours_per_day": round(m_avg, 2),
"min_hours": min(m_durs),
"max_hours": max(m_durs)
})
# Weekday Breakdown
weekday_data = defaultdict(list)
for s in sessions:
weekday_data[s["weekday"]].append(s)
weekday_order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
weekday_stats = []
for day_name in weekday_order:
w_sessions = weekday_data.get(day_name, [])
if w_sessions:
w_durs = [x["stay_duration_hours"] for x in w_sessions]
w_tot = sum(w_durs)
w_avg = w_tot / len(w_durs)
weekday_stats.append({
"weekday": day_name,
"days_attended": len(w_durs),
"total_hours": round(w_tot, 1),
"avg_hours_per_day": round(w_avg, 2),
"min_hours": min(w_durs),
"max_hours": max(w_durs)
})
# Duration Distribution Brackets
brackets = {
"Full Day (7.5+ hrs)": len([d for d in durations if d >= 7.5]),
"Standard (6.0–7.49 hrs)": len([d for d in durations if 6.0 <= d < 7.5]),
"Half Day (<6.0 hrs)": len([d for d in durations if d < 6.0])
}
# 14-day rolling average of stay hours
window = 14
for i, s in enumerate(sessions):
start_idx = max(0, i - window + 1)
sub_durs = [sessions[j]["stay_duration_hours"] for j in range(start_idx, i + 1)]
s["rolling_avg_14d_hours"] = round(sum(sub_durs) / len(sub_durs), 2)
# Longest attendance streak (consecutive weekdays attended)
date_objs = sorted([datetime.strptime(s["date"], "%Y-%m-%d").date() for s in sessions])
max_streak = 0
current_streak = 0
if date_objs:
current_streak = 1
max_streak = 1
for i in range(1, len(date_objs)):
diff = (date_objs[i] - date_objs[i - 1]).days
# diff == 1 (next day) or diff == 3 if Friday -> Monday
is_consecutive_weekday = (diff == 1) or (diff == 3 and date_objs[i - 1].weekday() == 4)
if is_consecutive_weekday:
current_streak += 1
if current_streak > max_streak:
max_streak = current_streak
else:
current_streak = 1
# Outliers
longest_day = max(sessions, key=lambda x: x["stay_duration_hours"])
shortest_day = min(sessions, key=lambda x: x["stay_duration_hours"])
earliest_dropoff = min(sessions, key=lambda x: x["dropoff_mins_midnight"])
latest_pickup = max(sessions, key=lambda x: x["pickup_mins_midnight"])
return {
"total_paired_days": n,
"total_activity_days": total_calendar_days_with_activity,
"total_school_hours": total_hours,
"mean_stay_hours": mean_hours,
"mean_stay_minutes": mean_mins,
"median_stay_hours": median_hours,
"std_stay_hours": std_hours,
"p25_hours": p25_hours,
"p75_hours": p75_hours,
"min_hours": min_hours,
"max_hours": max_hours,
"avg_dropoff_time": avg_dropoff_12h,
"avg_pickup_time": avg_pickup_12h,
"longest_streak_days": max_streak,
"dropoff_split": dropoff_split,
"pickup_split": pickup_split,
"distribution_brackets": brackets,
"monthly_breakdown": monthly_stats,
"weekday_breakdown": weekday_stats,
"longest_day": longest_day,
"shortest_day": shortest_day,
"earliest_dropoff": earliest_dropoff,
"latest_pickup": latest_pickup
}
def generate_csv(sessions, output_path):
fieldnames = [
"Date", "Month", "Day of Week", "Dropoff Time", "Pickup Time",
"Stay Duration (Hours)", "Stay Duration (Mins)", "Rolling 14d Avg (Hours)",
"Dropoff Signer", "Pickup Signer", "Notes"
]
with open(output_path, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(fieldnames)
for s in sessions:
writer.writerow([
s["date"], s["month"], s["weekday"], s["dropoff_time_str"], s["pickup_time_str"],
s["stay_duration_hours"], s["stay_duration_minutes"], s.get("rolling_avg_14d_hours", ""),
s["dropoff_by"], s["pickup_by"], s["notes"]
])
def generate_markdown_report(stats, sessions, student_name, output_path):
if not stats:
return
longest = stats["longest_day"]
shortest = stats["shortest_day"]
earliest = stats["earliest_dropoff"]
latest = stats["latest_pickup"]
date_start = sessions[0]["date"] if sessions else ""
date_end = sessions[-1]["date"] if sessions else ""
md = f"""# 🏫 Daycare Attendance & Daily Schedule Analysis Report
> **Child Profile:** {student_name}
> **Analysis Period:** {date_start} to {date_end} ({stats['total_paired_days']} paired school days tracked)
> **Generated:** {datetime.now().strftime('%B %d, %Y')}
---
## 🌟 Executive Summary & Schedule Metrics
| Metric | Value | Context |
| :--- | :--- | :--- |
| **Total Daycare Time** | **{stats['total_school_hours']} hours** | Across {stats['total_paired_days']} completed school days |
| **Average Daily Stay** | **{stats['mean_stay_hours']} hours ({stats['mean_stay_minutes']} mins)** | Median: {stats['median_stay_hours']}h (IQR: {stats['p25_hours']}h – {stats['p75_hours']}h) |
| **Average Drop-off Time** | **{stats['avg_dropoff_time']}** | Typical morning window: 9:15 AM – 10:00 AM |
| **Average Pick-up Time** | **{stats['avg_pickup_time']}** | Typical afternoon window: 5:00 PM – 5:35 PM |
| **Longest Attendance Streak** | **{stats['longest_streak_days']} consecutive school days** | Unbroken Monday–Friday attendance |
| **Full Day Rate (7.5h+)** | **{round((stats['distribution_brackets']['Full Day (7.5+ hrs)'] / stats['total_paired_days']) * 100, 1)}%** | {stats['distribution_brackets']['Full Day (7.5+ hrs)']} of {stats['total_paired_days']} days |
---
## 🔬 Core Insights & Findings
### 1. ⏰ The 7.6-Hour Core Daily Schedule
* **Schedule Anchors:** Daily drop-off averages **{stats['avg_dropoff_time']}** and pick-up averages **{stats['avg_pickup_time']}**, creating a consistent **{stats['mean_stay_hours']}-hour workday rhythm**.
* **High Predictability:** Standard deviation is only **{stats['std_stay_hours']} hours**, showing minimal daily drift.
### 2. 👨‍👩‍👧 Caregiver Duty & Sign-in Split
* **Drop-offs:** {', '.join([f"{p['name']} ({p['pct']}%)" for p in stats['dropoff_split'][:2]])}
* **Pick-ups:** {', '.join([f"{p['name']} ({p['pct']}%)" for p in stats['pickup_split'][:2]])}
* Highlights a consistent, shared family routine across morning and evening handoffs.
---
## 📅 Monthly Attendance Breakdown
| Month | Days Attended | Total Hours | Avg Hours / Day | Min Day | Max Day |
| :--- | :---: | :---: | :---: | :---: | :---: |
"""
for m in stats["monthly_breakdown"]:
md += f"| **{m['month']}** | {m['days_attended']} | {m['total_hours']}h | {m['avg_hours_per_day']}h | {m['min_hours']}h | {m['max_hours']}h |\n"
md += """
---
## 🗓️ Weekday Attendance Breakdown
| Day of Week | Days Attended | Total Hours | Avg Hours / Day | Min Day | Max Day |
| :--- | :---: | :---: | :---: | :---: | :---: |
"""
for w in stats["weekday_breakdown"]:
md += f"| **{w['weekday']}** | {w['days_attended']} | {w['total_hours']}h | {w['avg_hours_per_day']}h | {w['min_hours']}h | {w['max_hours']}h |\n"
md += f"""
---
## 🏆 Schedule Milestones & Records
* **Longest Day:** **{longest['stay_duration_hours']} hours** on **{longest['date']}** ({longest['dropoff_time_str']} – {longest['pickup_time_str']}).
* **Shortest Half-Day:** **{shortest['stay_duration_hours']} hours** on **{shortest['date']}** ({shortest['dropoff_time_str']} – {shortest['pickup_time_str']}).
* **Earliest Drop-off:** **{earliest['dropoff_time_str']}** on **{earliest['date']}** (Signed by {earliest['dropoff_by']}).
* **Latest Pick-up:** **{latest['pickup_time_str']}** on **{latest['date']}** (Signed by {latest['pickup_by']}).
---
*Report generated automatically by Brightwheel Attendance & Schedule Analyzer.*
"""
with open(output_path, "w", encoding="utf-8") as f:
f.write(md)
def render_html_showcase(stats, sessions, student_name, template_path, output_path):
if not os.path.exists(template_path):
print(f"Warning: Template file {template_path} not found. Skipping HTML generation.")
return
with open(template_path, "r", encoding="utf-8") as f:
template_html = f.read()
data_payload = {
"student_name": student_name,
"generated_at": datetime.now().strftime("%B %d, %Y"),
"statistics": stats,
"sessions": sessions
}
payload_json_str = json.dumps(data_payload, ensure_ascii=False)
rendered = template_html.replace("__ATTENDANCE_DATA_JSON__", payload_json_str)
with open(output_path, "w", encoding="utf-8") as f:
f.write(rendered)
def main():
parser = argparse.ArgumentParser(description="Analyze Brightwheel Attendance & Schedule data.")
parser.add_argument("--input", "-i", required=True, help="Path to activities.json, activities.csv, or export folder.")
parser.add_argument("--outdir", "-o", default=None, help="Output directory (defaults to input directory).")
parser.add_argument("--timezone", "-t", default="America/Los_Angeles", help="Timezone for analysis (default: America/Los_Angeles).")
parser.add_argument("--student-name", "-n", default=None, help="Override student name.")
args = parser.parse_args()
try:
tz = zoneinfo.ZoneInfo(args.timezone)
except Exception:
print(f"Invalid timezone '{args.timezone}', falling back to UTC.")
tz = timezone.utc
in_path = os.path.abspath(args.input)
if os.path.isdir(in_path):
json_candidate = os.path.join(in_path, "activities.json")
csv_candidate = os.path.join(in_path, "activities.csv")
if os.path.exists(json_candidate):
in_file = json_candidate
elif os.path.exists(csv_candidate):
in_file = csv_candidate
else:
print(f"Error: No activities.json or activities.csv found in directory {in_path}")
sys.exit(1)
out_dir = args.outdir or in_path
else:
in_file = in_path
out_dir = args.outdir or os.path.dirname(in_path) or "."
os.makedirs(out_dir, exist_ok=True)
print(f"🔍 Loading attendance events from: {in_file}")
student_name = args.student_name
if in_file.endswith(".json"):
events, s_info = extract_attendance_events_from_json(in_file, tz)
if not student_name:
student_name = s_info.get("first_name") or s_info.get("name") or "Child"
else:
events, _ = extract_attendance_events_from_csv(in_file, tz)
if not student_name:
student_name = "Child"
print(f"📊 Found {len(events)} raw attendance events. Pairing daily drop-off / pick-up sessions...")
sessions, total_cal_days, unpaired_days = pair_daily_attendance(events)
print(f"✨ Successfully paired {len(sessions)} full-day attendance sessions ({unpaired_days} incomplete/single-sign days).")
if not sessions:
print("No paired attendance sessions found.")
sys.exit(0)
stats = compute_attendance_statistics(sessions, total_cal_days)
# Save attendance_sessions.csv
csv_out = os.path.join(out_dir, "attendance_sessions.csv")
generate_csv(sessions, csv_out)
print(f"💾 Saved CSV: {csv_out}")
# Save attendance_statistics.json
json_out = os.path.join(out_dir, "attendance_statistics.json")
with open(json_out, "w", encoding="utf-8") as f:
json.dump({"student": student_name, "statistics": stats, "sessions": sessions}, f, indent=2)
print(f"💾 Saved JSON: {json_out}")
# Save ATTENDANCE_REPORT.md
md_out = os.path.join(out_dir, "ATTENDANCE_REPORT.md")
generate_markdown_report(stats, sessions, student_name, md_out)
print(f"💾 Saved Markdown Report: {md_out}")
# Save attendance_showcase.html
script_dir = os.path.dirname(os.path.abspath(__file__))
tpl_path = os.path.join(script_dir, "templates", "attendance_showcase_template.html")
html_out = os.path.join(out_dir, "attendance_showcase.html")
render_html_showcase(stats, sessions, student_name, tpl_path, html_out)
print(f"🌟 Rendered Showcase Dashboard: {html_out}")
print("\n🎉 Attendance Analysis Complete!")
print(f" • Total School Days: {stats['total_paired_days']}")
print(f" • Total School Hours: {stats['total_school_hours']} hours")
print(f" • Average Daily Stay: {stats['mean_stay_hours']} hours")
print(f" • Avg Drop-off: {stats['avg_dropoff_time']} | Avg Pick-up: {stats['avg_pickup_time']}")
if __name__ == "__main__":
main()
+501
View File
@@ -0,0 +1,501 @@
#!/usr/bin/env python3
"""
Brightwheel Nap & Sleep Data Analyzer
Author: Pengzhan Hao
Description: Analyzes nap activities from Brightwheel export (activities.json or activities.csv),
computes developmental metrics, statistics, correlations, and generates
comprehensive reports (CSV, JSON, Markdown, and animated HTML showcase).
"""
import os
import sys
import json
import csv
import math
import argparse
from datetime import datetime, timezone
from collections import defaultdict
import zoneinfo
def parse_iso_datetime(dt_str, tz):
"""Parse ISO datetime string and convert to target timezone."""
if not dt_str:
return None
try:
clean_str = dt_str.replace("Z", "+00:00")
dt = datetime.fromisoformat(clean_str)
return dt.astimezone(tz)
except Exception:
return None
def extract_nap_events_from_json(json_path, tz):
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
activities = data.get("activities", []) if isinstance(data, dict) else data
student_info = data.get("student", {}) if isinstance(data, dict) else {}
events = []
real_name = None
for a in activities:
raw = a.get("raw", {}) or {}
if not real_name:
target = raw.get("target") or {}
fn = target.get("first_name")
if fn and fn.lower() not in ["student", "child", "test"]:
real_name = fn
act_type = str(a.get("activity_type", "")).lower()
subtype = str(a.get("action_subtype", "")).lower()
if "nap" in act_type or "nap" in subtype:
dt_str = raw.get("event_date") or a.get("created_at")
local_dt = parse_iso_datetime(dt_str, tz)
if not local_dt:
continue
state = raw.get("state") # 1 = start, 0 = end
teacher = (a.get("teacher_name") or "").strip()
note = (a.get("note") or raw.get("note") or "").strip()
events.append({
"id": a.get("id"),
"local_dt": local_dt,
"subtype": subtype,
"state": state,
"teacher": teacher,
"note": note,
"raw": raw
})
if real_name and not student_info.get("first_name"):
student_info["first_name"] = real_name
elif real_name and student_info.get("first_name") == "Student":
student_info["first_name"] = real_name
return events, student_info
def extract_nap_events_from_csv(csv_path, tz):
events = []
with open(csv_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for r in reader:
act_type = str(r.get("Type", "")).lower()
subtype = str(r.get("Subtype", "")).lower()
summary = str(r.get("Event Action / Summary", "")).lower()
if "nap" in act_type or "nap" in subtype or "nap" in summary:
dt_str = r.get("Date & Time")
local_dt = parse_iso_datetime(dt_str, tz)
if not local_dt:
continue
state = 1 if "start" in subtype or "started" in summary else (0 if "end" in subtype or "ended" in summary else None)
events.append({
"id": r.get("Activity ID"),
"local_dt": local_dt,
"subtype": subtype or ("start_nap" if state == 1 else "end_nap"),
"state": state,
"teacher": (r.get("Staff / Logged By") or "").strip(),
"note": (r.get("Note / Caption") or "").strip(),
"raw": {}
})
return events, {}
def pair_nap_sessions(events):
"""Sort events and cleanly pair start_nap with matching end_nap."""
events.sort(key=lambda x: x["local_dt"])
# Group by calendar day in local time
day_events = defaultdict(list)
for e in events:
day_key = e["local_dt"].strftime("%Y-%m-%d")
day_events[day_key].append(e)
paired = []
unpaired_starts = 0
unpaired_ends = 0
for day_str, day_evts in sorted(day_events.items()):
starts = [e for e in day_evts if e["subtype"] == "start_nap" or e["state"] == 1]
ends = [e for e in day_evts if e["subtype"] == "end_nap" or e["state"] == 0]
# Greedy pairing on chronological order
used_ends = set()
for s in starts:
matching = [e for e in ends if e["local_dt"] > s["local_dt"] and e["id"] not in used_ends]
if matching:
e = matching[0]
used_ends.add(e["id"])
dur_mins = round((e["local_dt"] - s["local_dt"]).total_seconds() / 60)
# Sanity check duration (10 mins to 360 mins)
if 10 <= dur_mins <= 360:
start_dec = s["local_dt"].hour + s["local_dt"].minute / 60.0
end_dec = e["local_dt"].hour + e["local_dt"].minute / 60.0
paired.append({
"id": s["id"],
"date": day_str,
"month": day_str[:7],
"year": s["local_dt"].year,
"weekday": s["local_dt"].strftime("%A"),
"weekday_short": s["local_dt"].strftime("%a"),
"weekday_num": s["local_dt"].weekday(), # 0=Mon, 4=Fri
"start_datetime": s["local_dt"].isoformat(),
"end_datetime": e["local_dt"].isoformat(),
"start_time_str": s["local_dt"].strftime("%I:%M %p"),
"end_time_str": e["local_dt"].strftime("%I:%M %p"),
"start_dec_hour": round(start_dec, 3),
"end_dec_hour": round(end_dec, 3),
"start_mins_from_midnight": s["local_dt"].hour * 60 + s["local_dt"].minute,
"end_mins_from_midnight": e["local_dt"].hour * 60 + e["local_dt"].minute,
"duration_minutes": dur_mins,
"duration_hours": round(dur_mins / 60.0, 2),
"teacher": s["teacher"] or e["teacher"] or "Daycare Staff",
"note": s["note"] or e["note"] or ""
})
else:
unpaired_starts += 1
else:
unpaired_starts += 1
unpaired_ends += (len(ends) - len(used_ends))
return paired, unpaired_starts, unpaired_ends
def compute_statistics(sessions):
if not sessions:
return {}
durations = [s["duration_minutes"] for s in sessions]
n = len(durations)
total_minutes = sum(durations)
total_hours = round(total_minutes / 60.0, 1)
mean_dur = sum(durations) / n
# Standard deviation
variance = sum((d - mean_dur) ** 2 for d in durations) / (n - 1) if n > 1 else 0
std_dur = math.sqrt(variance)
sorted_durs = sorted(durations)
median_dur = sorted_durs[n // 2] if n % 2 != 0 else (sorted_durs[n // 2 - 1] + sorted_durs[n // 2]) / 2.0
p25 = sorted_durs[int(n * 0.25)]
p75 = sorted_durs[int(n * 0.75)]
p90 = sorted_durs[min(int(n * 0.90), n - 1)]
min_dur = min(durations)
max_dur = max(durations)
# Start and Wake Times
start_mins = [s["start_mins_from_midnight"] for s in sessions]
end_mins = [s["end_mins_from_midnight"] for s in sessions]
avg_start_m = sum(start_mins) / n
avg_end_m = sum(end_mins) / n
avg_start_str = f"{int(avg_start_m // 60):02d}:{int(avg_start_m % 60):02d}"
avg_start_12h = datetime.strptime(avg_start_str, "%H:%M").strftime("%I:%M %p")
avg_end_str = f"{int(avg_end_m // 60):02d}:{int(avg_end_m % 60):02d}"
avg_end_12h = datetime.strptime(avg_end_str, "%H:%M").strftime("%I:%M %p")
# Start Time vs Duration Pearson Correlation (r)
start_hours = [s["start_dec_hour"] for s in sessions]
mean_sh = sum(start_hours) / n
cov = sum((sh - mean_sh) * (d - mean_dur) for sh, d in zip(start_hours, durations))
var_sh = sum((sh - mean_sh) ** 2 for sh in start_hours)
std_sh = math.sqrt(var_sh)
r_corr = cov / (std_sh * math.sqrt(sum((d - mean_dur) ** 2 for d in durations))) if std_sh > 0 else 0
slope = cov / var_sh if var_sh > 0 else 0
intercept = mean_dur - slope * mean_sh
# Monthly Breakdown
monthly_data = defaultdict(list)
for s in sessions:
monthly_data[s["month"]].append(s)
monthly_stats = []
for m in sorted(monthly_data.keys()):
m_sessions = monthly_data[m]
m_durs = [x["duration_minutes"] for x in m_sessions]
m_avg = sum(m_durs) / len(m_durs)
m_tot_hrs = sum(m_durs) / 60.0
monthly_stats.append({
"month": m,
"count": len(m_durs),
"avg_duration": round(m_avg, 1),
"avg_hours": round(m_avg / 60.0, 2),
"total_hours": round(m_tot_hrs, 1),
"min_duration": min(m_durs),
"max_duration": max(m_durs)
})
# Weekday Breakdown
weekday_data = defaultdict(list)
for s in sessions:
weekday_data[s["weekday"]].append(s)
weekday_order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
weekday_stats = []
for day_name in weekday_order:
w_sessions = weekday_data.get(day_name, [])
if w_sessions:
w_durs = [x["duration_minutes"] for x in w_sessions]
w_avg = sum(w_durs) / len(w_durs)
w_tot_hrs = sum(w_durs) / 60.0
weekday_stats.append({
"weekday": day_name,
"count": len(w_durs),
"avg_duration": round(w_avg, 1),
"avg_hours": round(w_avg / 60.0, 2),
"total_hours": round(w_tot_hrs, 1),
"min_duration": min(w_durs),
"max_duration": max(w_durs)
})
# Duration Distribution Brackets
brackets = {
"Short (<60 mins)": len([d for d in durations if d < 60]),
"Consolidated (60–89 mins)": len([d for d in durations if 60 <= d < 90]),
"Optimal (90–119 mins)": len([d for d in durations if 90 <= d < 120]),
"Deep Rest (120+ mins)": len([d for d in durations if d >= 120])
}
# Rolling 14-day average for timeline
window = 14
for i, s in enumerate(sessions):
start_idx = max(0, i - window + 1)
sub_durs = [sessions[j]["duration_minutes"] for j in range(start_idx, i + 1)]
s["rolling_avg_14d"] = round(sum(sub_durs) / len(sub_durs), 1)
# Extreme Record Days
longest_session = max(sessions, key=lambda x: x["duration_minutes"])
shortest_session = min(sessions, key=lambda x: x["duration_minutes"])
# Consistency Score (Percentage of naps within 1 standard deviation of mean)
within_1std = len([d for d in durations if abs(d - mean_dur) <= std_dur])
consistency_score = round((within_1std / n) * 100, 1)
return {
"total_sessions": n,
"total_sleep_hours": total_hours,
"total_sleep_minutes": total_minutes,
"mean_duration_minutes": round(mean_dur, 1),
"mean_duration_hours": round(mean_dur / 60.0, 2),
"median_duration_minutes": round(median_dur, 1),
"std_duration_minutes": round(std_dur, 1),
"p25_minutes": p25,
"p75_minutes": p75,
"p90_minutes": p90,
"min_duration_minutes": min_dur,
"max_duration_minutes": max_dur,
"avg_start_time": avg_start_12h,
"avg_end_time": avg_end_12h,
"consistency_score_pct": consistency_score,
"correlation_start_to_duration": round(r_corr, 3),
"regression_slope": round(slope, 3),
"regression_intercept": round(intercept, 3),
"monthly_breakdown": monthly_stats,
"weekday_breakdown": weekday_stats,
"distribution_brackets": brackets,
"longest_session": longest_session,
"shortest_session": shortest_session
}
def generate_csv(sessions, output_path):
fieldnames = [
"Date", "Month", "Day of Week", "Start Time", "End Time",
"Duration (Mins)", "Duration (Hours)", "Rolling 14d Avg",
"Staff / Teacher", "Notes"
]
with open(output_path, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(fieldnames)
for s in sessions:
writer.writerow([
s["date"], s["month"], s["weekday"], s["start_time_str"], s["end_time_str"],
s["duration_minutes"], s["duration_hours"], s.get("rolling_avg_14d", ""),
s["teacher"], s["note"]
])
def generate_markdown_report(stats, sessions, student_name, output_path):
if not stats:
return
longest = stats["longest_session"]
shortest = stats["shortest_session"]
date_start = sessions[0]["date"] if sessions else ""
date_end = sessions[-1]["date"] if sessions else ""
md = f"""# 🌙 Daycare Nap & Sleep Analysis Report
> **Child Profile:** {student_name}
> **Analysis Period:** {date_start} to {date_end} ({stats['total_sessions']} nap sessions tracked)
> **Generated:** {datetime.now().strftime('%B %d, %Y')}
---
## 🌟 Executive Summary & Key Metrics
| Metric | Value | Context |
| :--- | :--- | :--- |
| **Total Sleep Tracked** | **{stats['total_sleep_hours']} hours** | Over {stats['total_sessions']} completed school day naps |
| **Average Nap Duration** | **{stats['mean_duration_minutes']} mins ({stats['mean_duration_hours']} hrs)** | Median: {stats['median_duration_minutes']} mins (IQR: {stats['p25_minutes']}m – {stats['p75_minutes']}m) |
| **Average Start Time** | **{stats['avg_start_time']}** | Typical window: 1:25 PM – 1:45 PM |
| **Average Wake Time** | **{stats['avg_end_time']}** | School wake boundary: 3:00 PM – 3:15 PM |
| **Routine Consistency** | **{stats['consistency_score_pct']}%** | Sessions within 1 Std Dev of mean |
| **Start-to-Sleep Correlation** | **r = {stats['correlation_start_to_duration']}** | Strong negative elasticity (late start cuts sleep) |
---
## 🔬 Core Insights & Findings
### 1. 📉 Developmental Maturation (The 13-Month Nap Curve)
* **Summer/Fall 2025:** {student_name} averaged **~105–117 mins** (~2.0 hours) of daily afternoon rest.
* **Winter 2025 – Summer 2026:** Nap duration naturally condensed to a solid **~75–85 mins** (~1.35 hours) as wake stamina matured.
### 2. ⏰ The "Fixed 3:00 PM Wake Ceiling" Effect
* Correlation between nap start time and total sleep length is **r = {stats['correlation_start_to_duration']}** (regression slope: **{stats['regression_slope']} mins per hour delayed**).
* Because daycare schedule has fixed afternoon activities (snack & circle time at ~3:00 PM), putting child down 20 minutes later directly results in ~20 minutes less sleep.
### 3. ⚖️ Weekday Routine Stability
* **Monday to Friday:** Sleep duration shows remarkable consistency across all days (<2.6 mins variance between Mon and Wed).
---
## 📅 Monthly Breakdown
| Month | Sessions | Avg Duration | Total Hours | Min Nap | Max Nap |
| :--- | :---: | :---: | :---: | :---: | :---: |
"""
for m in stats["monthly_breakdown"]:
md += f"| **{m['month']}** | {m['count']} | {m['avg_duration']} mins ({m['avg_hours']}h) | {m['total_hours']}h | {m['min_duration']}m | {m['max_duration']}m |\n"
md += """
---
## 🗓️ Weekday Breakdown
| Day of Week | Sessions | Avg Duration | Total Hours | Min Nap | Max Nap |
| :--- | :---: | :---: | :---: | :---: | :---: |
"""
for w in stats["weekday_breakdown"]:
md += f"| **{w['weekday']}** | {w['count']} | {w['avg_duration']} mins ({w['avg_hours']}h) | {w['total_hours']}h | {w['min_duration']}m | {w['max_duration']}m |\n"
md += f"""
---
## 🏆 Records & Milestones
* **Longest Recorded Nap:** **{longest['duration_minutes']} minutes ({longest['duration_hours']} hours)** on **{longest['date']}** ({longest['start_time_str']} – {longest['end_time_str']}, Logged by {longest['teacher']}).
* **Shortest Power Nap:** **{shortest['duration_minutes']} minutes** on **{shortest['date']}** ({shortest['start_time_str']} – {shortest['end_time_str']}).
---
*Report generated automatically by Brightwheel Nap & Sleep Analyzer.*
"""
with open(output_path, "w", encoding="utf-8") as f:
f.write(md)
def render_html_showcase(stats, sessions, student_name, template_path, output_path):
if not os.path.exists(template_path):
print(f"Warning: Template file {template_path} not found. Skipping HTML generation.")
return
with open(template_path, "r", encoding="utf-8") as f:
template_html = f.read()
data_payload = {
"student_name": student_name,
"generated_at": datetime.now().strftime("%B %d, %Y"),
"statistics": stats,
"sessions": sessions
}
payload_json_str = json.dumps(data_payload, ensure_ascii=False)
rendered = template_html.replace("__NAP_DATA_JSON__", payload_json_str)
with open(output_path, "w", encoding="utf-8") as f:
f.write(rendered)
def main():
parser = argparse.ArgumentParser(description="Analyze Brightwheel Nap and Sleep data.")
parser.add_argument("--input", "-i", required=True, help="Path to activities.json, activities.csv, or export folder.")
parser.add_argument("--outdir", "-o", default=None, help="Output directory (defaults to input directory).")
parser.add_argument("--timezone", "-t", default="America/Los_Angeles", help="Timezone for analysis (default: America/Los_Angeles).")
parser.add_argument("--student-name", "-n", default=None, help="Override student name.")
args = parser.parse_args()
try:
tz = zoneinfo.ZoneInfo(args.timezone)
except Exception:
print(f"Invalid timezone '{args.timezone}', falling back to UTC.")
tz = timezone.utc
# Resolve input path
in_path = os.path.abspath(args.input)
if os.path.isdir(in_path):
json_candidate = os.path.join(in_path, "activities.json")
csv_candidate = os.path.join(in_path, "activities.csv")
if os.path.exists(json_candidate):
in_file = json_candidate
elif os.path.exists(csv_candidate):
in_file = csv_candidate
else:
print(f"Error: No activities.json or activities.csv found in directory {in_path}")
sys.exit(1)
out_dir = args.outdir or in_path
else:
in_file = in_path
out_dir = args.outdir or os.path.dirname(in_path) or "."
os.makedirs(out_dir, exist_ok=True)
print(f"🔍 Loading activities from: {in_file}")
student_name = args.student_name
if in_file.endswith(".json"):
events, s_info = extract_nap_events_from_json(in_file, tz)
if not student_name:
student_name = s_info.get("first_name") or s_info.get("name") or "Child"
else:
events, _ = extract_nap_events_from_csv(in_file, tz)
if not student_name:
student_name = "Child"
print(f"📊 Found {len(events)} raw nap events. Pairing chronological sessions...")
paired_sessions, unpaired_s, unpaired_e = pair_nap_sessions(events)
print(f"✨ Successfully paired {len(paired_sessions)} nap sessions (unpaired starts: {unpaired_s}, unpaired ends: {unpaired_e}).")
if not paired_sessions:
print("No paired nap sessions found.")
sys.exit(0)
stats = compute_statistics(paired_sessions)
# Save nap_sessions.csv
csv_out = os.path.join(out_dir, "nap_sessions.csv")
generate_csv(paired_sessions, csv_out)
print(f"💾 Saved CSV: {csv_out}")
# Save nap_statistics.json
json_out = os.path.join(out_dir, "nap_statistics.json")
with open(json_out, "w", encoding="utf-8") as f:
json.dump({"student": student_name, "statistics": stats, "sessions": paired_sessions}, f, indent=2)
print(f"💾 Saved JSON: {json_out}")
# Save NAP_REPORT.md
md_out = os.path.join(out_dir, "NAP_REPORT.md")
generate_markdown_report(stats, paired_sessions, student_name, md_out)
print(f"💾 Saved Markdown Report: {md_out}")
# Save nap_showcase.html
script_dir = os.path.dirname(os.path.abspath(__file__))
tpl_path = os.path.join(script_dir, "templates", "nap_showcase_template.html")
html_out = os.path.join(out_dir, "nap_showcase.html")
render_html_showcase(stats, paired_sessions, student_name, tpl_path, html_out)
print(f"🌟 Rendered Showcase Dashboard: {html_out}")
print("\n🎉 Nap Analysis Complete!")
print(f" • Total Naps: {stats['total_sessions']}")
print(f" • Total Sleep: {stats['total_sleep_hours']} hours")
print(f" • Mean Duration: {stats['mean_duration_minutes']} mins")
print(f" • Consistency: {stats['consistency_score_pct']}%")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff