4 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
haopengzhan 7c48f3c0b0 docs: update repository and clone URLs to self-hosted git.pengzhan.dev
CI Verification / Lint & Extension Integrity Check (push) Successful in 2m39s
2026-08-23 15:33:15 -07:00
15 changed files with 4006 additions and 193 deletions
+6 -4
View File
@@ -29,12 +29,13 @@ jobs:
id: version id: version
run: | run: |
VERSION=$(node -p "require('./manifest.json').version") 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" echo "Building extension release for version: $VERSION"
- name: Build Production Extension ZIP - name: Build Production Extension ZIP
id: package
run: | 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..." echo "📦 Packaging $ZIP_NAME..."
zip -r "$ZIP_NAME" . \ zip -r "$ZIP_NAME" . \
-x "*.git*" \ -x "*.git*" \
@@ -49,13 +50,14 @@ jobs:
-x "LICENSE" \ -x "LICENSE" \
-x "*.zip" -x "*.zip"
echo "ZIP_FILE=$ZIP_NAME" >> $GITHUB_ENV echo "zip_file=$ZIP_NAME" >> "$GITHUB_OUTPUT"
echo "✅ Package contents verified:" echo "✅ Package contents verified:"
unzip -l "$ZIP_NAME" unzip -l "$ZIP_NAME"
- name: Upload ZIP to GitHub Release Assets - name: Upload ZIP to GitHub Release Assets
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: ${{ env.ZIP_FILE }} files: ${{ steps.package.outputs.zip_file }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -4,7 +4,7 @@ Thumbs.db
*.tmp *.tmp
# User Export Data & Backups (Prevent leaking child photos/videos/records) # User Export Data & Backups (Prevent leaking child photos/videos/records)
emmy/ emmy*/
exports/ exports/
data/ data/
test_data/ test_data/
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 CoderSherlock Copyright (c) 2026 Pengzhan Hao
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+18 -5
View File
@@ -10,16 +10,28 @@ DIST_DIR := dist
ZIP_NAME := brightwheel-exporter-v$(VERSION).zip ZIP_NAME := brightwheel-exporter-v$(VERSION).zip
ZIP_PATH := $(DIST_DIR)/$(ZIP_NAME) 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 all: build
## help: Display this help message ## help: Display this help message
help: help:
@echo "Available commands:" @echo "Available commands:"
@echo " make build (or make zip) - Build the Chrome Web Store production ZIP package" @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 check - Verify JavaScript syntax and manifest validity"
@echo " make clean - Remove the dist directory and build artifacts" @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: Verify JavaScript syntax and manifest validity
check: check:
@@ -40,10 +52,11 @@ build: check
-x ".githooks/*" \ -x ".githooks/*" \
-x ".github/*" \ -x ".github/*" \
-x "$(DIST_DIR)/*" \ -x "$(DIST_DIR)/*" \
-x "emmy/*" \ -x "tools/*" \
-x "exports/*" \ -x "exports/*" \
-x "data/*" \ -x "data/*" \
-x "test_data/*" \ -x "test_data/*" \
-x "*_export/*" \
-x "screenshots/*" \ -x "screenshots/*" \
-x "Makefile" \ -x "Makefile" \
-x "*.md" \ -x "*.md" \
+7 -6
View File
@@ -1,14 +1,14 @@
# Privacy Policy for Brightwheel Memories & Reports Exporter # Privacy Policy for Brightwheel Memories & Reports Exporter
**Last Updated:** August 23, 2026 **Last Updated:** August 23, 2026
**Developer:** CoderSherlock **Developer:** Pengzhan Hao
**Repository:** [https://github.com/CoderSherlock/brightwheel-exporter](https://github.com/CoderSherlock/brightwheel-exporter) **Repository:** [https://git.pengzhan.dev/haopengzhan/brightwheel-exporter](https://git.pengzhan.dev/haopengzhan/brightwheel-exporter)
--- ---
## 1. Overview & Commitment to Privacy ## 1. Overview & Commitment to Privacy
The **Brightwheel Memories & Reports Exporter** Chrome Extension was built with a strict **Privacy-First** architecture. We believe your family's personal memories, child photos, videos, and daily records belong exclusively to you. The **Brightwheel Memories & Reports Exporter** Chrome Extension was built with a strict **Privacy-First** architecture. We believe your family's personal memories, child photos, videos, and daily records belong exclusively to you.
**This extension does NOT collect, store, track, sell, or transmit any personal data, media, credentials, or telemetry to the developer or any third-party servers.** **This extension does NOT collect, store, track, sell, or transmit any personal data, media, credentials, or telemetry to the developer or any third-party servers.**
@@ -38,6 +38,7 @@ This extension interacts only with Brightwheel's web application to help users b
## 5. Contact & Open Source Verification ## 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 on GitHub: 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:
- **GitHub Repository:** [https://github.com/CoderSherlock/brightwheel-exporter](https://github.com/CoderSherlock/brightwheel-exporter)
- **Report Issues / Inquiries:** [https://github.com/CoderSherlock/brightwheel-exporter/issues](https://github.com/CoderSherlock/brightwheel-exporter/issues) - **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)
+3 -3
View File
@@ -28,7 +28,7 @@
### Option A: Download ZIP (No Coding or Git Required) ### Option A: Download ZIP (No Coding or Git Required)
1. Download the latest source code by clicking the green **Code** button at the top of this page → **[Download ZIP](https://github.com/CoderSherlock/brightwheel-exporter/archive/refs/heads/main.zip)**. 1. Download the latest source code by clicking the **Download ZIP** button on the repository page → **[Download ZIP](https://git.pengzhan.dev/haopengzhan/brightwheel-exporter/archive/main.zip)**.
2. Unzip the downloaded `.zip` file on your computer. 2. Unzip the downloaded `.zip` file on your computer.
3. Open Google Chrome and navigate to: 3. Open Google Chrome and navigate to:
@@ -38,13 +38,13 @@
4. Enable **Developer mode** using the toggle switch in the top-right corner. 4. Enable **Developer mode** using the toggle switch in the top-right corner.
5. Click the **Load unpacked** button in the top-left corner. 5. Click the **Load unpacked** button in the top-left corner.
6. Select the unzipped `brightwheel-exporter-main` folder. 6. Select the unzipped `brightwheel-exporter` folder.
7. 🎉 The **Brightwheel Exporter** icon will now appear in your browser toolbar! 7. 🎉 The **Brightwheel Exporter** icon will now appear in your browser toolbar!
### Option B: Clone via Git (For Developers) ### Option B: Clone via Git (For Developers)
```bash ```bash
git clone https://github.com/CoderSherlock/brightwheel-exporter.git git clone https://git.pengzhan.dev/haopengzhan/brightwheel-exporter.git
``` ```
Then follow steps 3–6 above selecting the cloned directory. Then follow steps 3–6 above selecting the cloned directory.
+2 -1
View File
@@ -63,7 +63,7 @@
<details class="manual-id-details" id="manualDetails"> <details class="manual-id-details" id="manualDetails">
<summary>Enter Student UUID Manually</summary> <summary>Enter Student UUID Manually</summary>
<div class="manual-input-row"> <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> <button id="setManualIdBtn" class="secondary-btn">Use ID</button>
</div> </div>
</details> </details>
@@ -291,6 +291,7 @@
<div class="card-header log-header"> <div class="card-header log-header">
<h3>Live Activity Log</h3> <h3>Live Activity Log</h3>
<div class="log-actions"> <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> <button id="clearLogBtn" class="link-btn">Clear</button>
</div> </div>
</div> </div>
+262 -146
View File
@@ -5,7 +5,7 @@
// Application State // Application State
const state = { const state = {
students: [], students: [],
selectedStudentId: 'edda433b-2a24-4da7-bbd5-9f131b4b2565', selectedStudentId: '',
selectedStudentData: null, selectedStudentData: null,
destinationDirectoryHandle: null, destinationDirectoryHandle: null,
isExporting: false, isExporting: false,
@@ -31,13 +31,13 @@ document.addEventListener('DOMContentLoaded', async () => {
initDOMElements(); initDOMElements();
setupEventListeners(); setupEventListeners();
initDatePresets(); initDatePresets();
if (window.JSZip || typeof JSZip !== 'undefined') { if (window.JSZip || typeof JSZip !== 'undefined') {
log('Zip compression engine initialized (JSZip v3.10.1).', 'info'); log('Zip compression engine initialized (JSZip v3.10.1).', 'info');
} else { } else {
log('Warning: JSZip library not detected on page load.', 'warn'); log('Warning: JSZip library not detected on page load.', 'warn');
} }
await checkSessionAndDiscoverStudents(); await checkSessionAndDiscoverStudents();
}); });
@@ -78,14 +78,15 @@ function initDOMElements() {
metricSize: document.getElementById('metricSize'), metricSize: document.getElementById('metricSize'),
metricRateLimit: document.getElementById('metricRateLimit'), metricRateLimit: document.getElementById('metricRateLimit'),
terminalLog: document.getElementById('terminalLog'), terminalLog: document.getElementById('terminalLog'),
clearLogBtn: document.getElementById('clearLogBtn') clearLogBtn: document.getElementById('clearLogBtn'),
inspectFeedBtn: document.getElementById('inspectFeedBtn')
}; };
} }
function log(message, type = 'info') { function log(message, type = 'info') {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const timeFormatted = new Date().toLocaleTimeString(); const timeFormatted = new Date().toLocaleTimeString();
state.executionLogs.push({ state.executionLogs.push({
timestamp, timestamp,
time: timeFormatted, time: timeFormatted,
@@ -104,65 +105,176 @@ function log(message, type = 'info') {
// Session & Student Discovery // 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() { async function checkSessionAndDiscoverStudents() {
log('Checking active Brightwheel browser session...', 'info'); log('Checking active Brightwheel browser session...', 'info');
// Check stored active student ID first
const stored = await chrome.storage.local.get(['lastActiveStudentId']); const stored = await chrome.storage.local.get(['lastActiveStudentId']);
if (stored.lastActiveStudentId) { if (stored.lastActiveStudentId) {
state.selectedStudentId = stored.lastActiveStudentId; state.selectedStudentId = stored.lastActiveStudentId;
el.manualStudentId.value = stored.lastActiveStudentId; el.manualStudentId.value = stored.lastActiveStudentId;
} }
// 1. Try communicating with active Brightwheel tab first (most accurate)
try {
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 { try {
// 1. Try to fetch user/guardian profile to list students
const userRes = await fetchBrightwheelAPI('https://schools.mybrightwheel.com/api/v1/users/me'); const userRes = await fetchBrightwheelAPI('https://schools.mybrightwheel.com/api/v1/users/me');
if (userRes && userRes.success && userRes.data) { if (userRes && userRes.success && userRes.data) {
el.sessionDot.classList.add('online'); el.sessionDot.classList.add('online');
el.sessionStatusText.textContent = 'Brightwheel Session Active'; el.sessionStatusText.textContent = 'Brightwheel Session Active';
log('Successfully connected to Brightwheel user profile.', 'success');
const userData = userRes.data; const found = extractStudentsFromPayload(userRes.data);
const studentsFound = userData.students || userData.children || (userData.guardians && userData.guardians[0]?.students) || []; if (found.length > 0) {
state.students = found;
if (studentsFound.length > 0) { if (!state.selectedStudentId) state.selectedStudentId = found[0].id;
state.students = studentsFound; state.selectedStudentData = found.find(s => s.id === state.selectedStudentId) || found[0];
el.manualStudentId.value = state.selectedStudentId;
renderStudentsList(); renderStudentsList();
log(`Loaded ${found.length} student profile(s) from user account.`, 'success');
return; 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) { if (state.selectedStudentId) {
const studentRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${state.selectedStudentId}`); const studentRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${state.selectedStudentId}`);
if (studentRes && studentRes.success && studentRes.data) { if (studentRes && studentRes.success && studentRes.data) {
el.sessionDot.classList.add('online'); el.sessionDot.classList.add('online');
el.sessionStatusText.textContent = 'Brightwheel Session Active'; el.sessionStatusText.textContent = 'Brightwheel Session Active';
const s = studentRes.data.student || studentRes.data; const s = studentRes.data.student || studentRes.data;
state.students = [s]; state.students = [s];
state.selectedStudentData = s; state.selectedStudentData = s;
renderStudentsList(); 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; return;
} }
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 Account'
}];
renderStudentsList();
log(`Using student UUID: ${state.selectedStudentId}`, 'info');
return;
} }
// Fallback: Default student card with manual ID // No student found yet
el.sessionDot.classList.add('online'); el.sessionStatusText.textContent = 'Open Brightwheel in another tab to auto-connect';
el.sessionStatusText.textContent = 'Session Connected';
state.students = [{
id: state.selectedStudentId,
first_name: 'Student',
last_name: `(${state.selectedStudentId.substring(0, 8)}...)`,
school_name: 'Brightwheel School'
}];
renderStudentsList(); renderStudentsList();
log(`Using student UUID: ${state.selectedStudentId}`, 'info');
} catch (err) { } catch (err) {
log(`Session check note: ${err.message}. Ready with manual ID.`, 'warn'); log(`Session check note: ${err.message}.`, 'warn');
renderStudentsList(); renderStudentsList();
} }
} }
@@ -190,9 +302,9 @@ async function fetchBrightwheelAPI(url) {
function renderStudentsList() { function renderStudentsList() {
el.studentsList.innerHTML = ''; el.studentsList.innerHTML = '';
if (state.students.length === 0) { 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; return;
} }
@@ -202,7 +314,7 @@ function renderStudentsList() {
const firstName = student.first_name || 'My'; const firstName = student.first_name || 'My';
const lastName = student.last_name || 'Child'; const lastName = student.last_name || 'Child';
const initials = (firstName[0] || 'B') + (lastName[0] || 'W'); 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'); const card = document.createElement('div');
card.className = `student-card ${isSelected ? 'selected' : ''}`; card.className = `student-card ${isSelected ? 'selected' : ''}`;
@@ -227,6 +339,7 @@ function renderStudentsList() {
state.selectedStudentId = sId; state.selectedStudentId = sId;
state.selectedStudentData = student; state.selectedStudentData = student;
el.manualStudentId.value = sId; el.manualStudentId.value = sId;
chrome.storage.local.set({ lastActiveStudentId: sId });
log(`Selected child: ${firstName} ${lastName} (${sId})`, 'info'); log(`Selected child: ${firstName} ${lastName} (${sId})`, 'info');
}); });
@@ -239,11 +352,8 @@ function renderStudentsList() {
// ------------------------------------------------------------- // -------------------------------------------------------------
function initDatePresets() { function initDatePresets() {
const today = new Date();
const currentYear = today.getFullYear();
el.startDate.value = ''; el.startDate.value = '';
el.endDate.value = today.toISOString().split('T')[0]; el.endDate.value = '';
} }
function setDateRange(rangeType) { function setDateRange(rangeType) {
@@ -252,7 +362,7 @@ function setDateRange(rangeType) {
if (rangeType === 'all') { if (rangeType === 'all') {
el.startDate.value = ''; el.startDate.value = '';
el.endDate.value = todayStr; el.endDate.value = '';
} else if (rangeType === 'this-year') { } else if (rangeType === 'this-year') {
el.startDate.value = `${today.getFullYear()}-01-01`; el.startDate.value = `${today.getFullYear()}-01-01`;
el.endDate.value = todayStr; el.endDate.value = todayStr;
@@ -387,45 +497,30 @@ async function handleInspectFeed() {
return; return;
} }
log(`✅ Live Inspection Complete for: ${res.currentUrl}`, 'success'); log(`✅ Live Tab Diagnostic Complete: ${res.currentUrl}`, 'success');
log(`Page Title: "${res.pageTitle}"`, 'info'); 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) { if (res.endpointProbes) {
res.domCards.slice(0, 3).forEach((dc, i) => { for (const [name, probe] of Object.entries(res.endpointProbes)) {
log(` [DOM Card ${i + 1}] Text: "${dc.raw_text.replace(/\s+/g, ' ').substring(0, 100)}..."`, 'info'); 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 // Automatically download inspection JSON report
const blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; 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); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); 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) { } catch (e) {
log('Inspection error: ' + e.message, 'error'); log('Inspection error: ' + e.message, 'error');
@@ -555,12 +650,11 @@ function updateMetrics() {
async function fetchAllActivities(studentId) { async function fetchAllActivities(studentId) {
const pageSize = 50; const pageSize = 50;
let page = 1; const startDate = el.startDate.value?.trim();
let hasMore = true; const endDate = el.endDate.value?.trim();
const startDate = el.startDate.value;
const endDate = el.endDate.value;
const pacingDelay = parseInt(el.concurrencySelect.value) || 200; const pacingDelay = parseInt(el.concurrencySelect.value) || 200;
const seenTypes = new Set(); const seenTypes = new Set();
const seenActivityIds = new Set();
updateProgress(5, 'Harvesting activity logs...'); updateProgress(5, 'Harvesting activity logs...');
@@ -578,102 +672,124 @@ async function fetchAllActivities(studentId) {
// Non-critical // Non-critical
} }
while (hasMore && !state.isCancelled) { // Harvester for a specific Brightwheel feed endpoint (0-indexed pagination)
while (state.isPaused && !state.isCancelled) { async function harvestEndpoint(endpointBaseUrl, label) {
await sleep(500); let page = 0;
} let hasMore = true;
if (state.isCancelled) break; log(`Harvesting from ${label}...`, 'info');
// Build URL query while (hasMore && !state.isCancelled) {
let url = `https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities?page_size=${pageSize}&page=${page}`; while (state.isPaused && !state.isCancelled) {
if (startDate) url += `&start_date=${startDate}`; await sleep(500);
if (endDate) url += `&end_date=${endDate}`; }
if (state.isCancelled) break;
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 response = null;
let retries = 0; let retries = 0;
const maxRetries = 5; const maxRetries = 5;
while (retries < maxRetries) { while (retries < maxRetries) {
response = await fetchBrightwheelAPI(url); response = await fetchBrightwheelAPI(url);
if (response && response.status === 429) { if (response && response.status === 429) {
state.stats.retryCount++; state.stats.retryCount++;
updateMetrics(); updateMetrics();
const backoffTime = Math.pow(2, retries) * 1000; const backoffTime = Math.pow(2, retries) * 1000;
log(`Rate limited (429). Backing off for ${backoffTime / 1000}s...`, 'warn'); log(`Rate limited (429). Backing off for ${backoffTime / 1000}s...`, 'warn');
await sleep(backoffTime); await sleep(backoffTime);
retries++; retries++;
} else { } else {
break;
}
}
const rawActivities = response?.data?.activities || response?.data?.items || (Array.isArray(response?.data) ? response.data : []);
if (!Array.isArray(rawActivities) || rawActivities.length === 0) {
break; break;
} }
}
if (!response || !response.success || !response.data) { for (const act of rawActivities) {
if (page === 1) { const actId = act.object_id || act.id;
log('Trying alternate activity feed endpoint...', 'info'); if (actId && seenActivityIds.has(actId)) {
const altUrl = `https://schools.mybrightwheel.com/api/v1/activities?student_ids[]=${studentId}&page_size=${pageSize}&page=${page}`; continue;
const altRes = await fetchBrightwheelAPI(altUrl); }
if (altRes && altRes.success && altRes.data) { if (actId) {
response = altRes; seenActivityIds.add(actId);
} }
}
}
const rawActivities = response?.data?.activities || response?.data?.items || response?.data || []; // Schema inspection for debugging
const actType = act.activity_type || act.action_type || act.type || 'unknown';
if (!seenTypes.has(actType)) {
seenTypes.add(actType);
const presentKeys = Object.keys(act).filter(k => act[k] !== null && act[k] !== undefined && act[k] !== '');
log(`[Schema] ${actType} fields: ${presentKeys.join(', ')}`, 'info');
}
if (!Array.isArray(rawActivities) || rawActivities.length === 0) { // If video activity has no immediate URL, fetch single activity detail
log(`No more activities found after page ${page - 1}. Done harvesting.`, 'info'); if ((actType === 'ac_video' || actType === 'video') && !act.video_url && !act.video_info && !act.media?.video_url) {
hasMore = false; try {
break; 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}`);
// Filter and normalize activities if (detailRes && detailRes.success && detailRes.data) {
for (const act of rawActivities) { const detailObj = detailRes.data.activity || detailRes.data;
// Schema inspection for debugging Object.assign(act, detailObj);
const actType = act.activity_type || act.action_type || act.type || 'unknown'; }
if (!seenTypes.has(actType)) { }
seenTypes.add(actType); } catch (e) {
const presentKeys = Object.keys(act).filter(k => act[k] !== null && act[k] !== undefined && act[k] !== ''); // Continue
log(`[Schema] ${actType} fields: ${presentKeys.join(', ')}`, 'info');
}
// If video activity has no immediate URL, fetch single activity detail
if ((actType === 'ac_video' || actType === 'video') && !act.video_url && !act.media?.video_url) {
try {
const detailRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities/${act.id}`);
if (detailRes && detailRes.success && detailRes.data) {
const detailObj = detailRes.data.activity || detailRes.data;
Object.assign(act, detailObj);
} }
} catch (e) { }
// Continue
if (matchesSelectedCategories(act)) {
state.activities.push(normalizeActivity(act));
} }
} }
if (matchesSelectedCategories(act)) { state.stats.activitiesCount = state.activities.length;
state.activities.push(normalizeActivity(act)); updateMetrics();
updateProgress(Math.min(30, 5 + (state.activities.length / 50) * 2), `Harvested ${state.activities.length} activities (Page ${page + 1})`);
if (rawActivities.length < pageSize) {
hasMore = false;
break;
}
page++;
await sleep(pacingDelay);
}
}
// 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})`);
} }
} }
state.stats.activitiesCount = state.activities.length;
updateMetrics();
updateProgress(Math.min(30, 5 + page * 2), `Harvested ${state.activities.length} activities (Page ${page})`);
if (rawActivities.length < pageSize) {
hasMore = false;
break;
}
page++;
await sleep(pacingDelay);
} }
// 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) { function matchesSelectedCategories(act) {
const type = (act.activity_type || act.action_type || act.type || '').toLowerCase(); const type = (act.activity_type || act.action_type || act.type || '').toLowerCase();
if (type.includes('photo') || type.includes('video') || act.media || act.image_url || act.video_url || act.video_stream_url) { 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 (el.catPhotos.checked) return true;
} }
@@ -947,7 +1063,7 @@ function capitalize(s) {
function extractMediaQueue() { function extractMediaQueue() {
state.mediaQueue = []; state.mediaQueue = [];
state.activities.forEach((act) => { state.activities.forEach((act) => {
const url = act.video_url || act.image_url; const url = act.video_url || act.image_url;
if (url) { if (url) {
@@ -1140,7 +1256,7 @@ async function buildAndDownloadZip(studentProfile) {
await writeBlobToDirectoryHandle(state.destinationDirectoryHandle, 'activities.json', exportJsonStr); await writeBlobToDirectoryHandle(state.destinationDirectoryHandle, 'activities.json', exportJsonStr);
} }
await writeBlobToDirectoryHandle(state.destinationDirectoryHandle, 'exporter_job_info.json', jobInfoStr); await writeBlobToDirectoryHandle(state.destinationDirectoryHandle, 'exporter_job_info.json', jobInfoStr);
if (csvContent) { if (csvContent) {
await writeBlobToDirectoryHandle(state.destinationDirectoryHandle, 'activities.csv', csvContent); await writeBlobToDirectoryHandle(state.destinationDirectoryHandle, 'activities.csv', csvContent);
} }
+7
View File
@@ -287,6 +287,13 @@
return; 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(); renderProfile();
renderFeed('all', ''); renderFeed('all', '');
setupEvents(); setupEvents();
+2 -1
View File
@@ -30,7 +30,8 @@
"permissions": [ "permissions": [
"storage", "storage",
"downloads", "downloads",
"tabs" "tabs",
"cookies"
], ],
"host_permissions": [ "host_permissions": [
"https://schools.mybrightwheel.com/*", "https://schools.mybrightwheel.com/*",
+267 -25
View File
@@ -6,13 +6,55 @@
(function () { (function () {
console.log('[Brightwheel Exporter] Content script initialized.'); console.log('[Brightwheel Exporter] Content script initialized.');
// Extract student ID from URL if present // Extract student ID from URL, DOM, or Storage
function detectCurrentStudentId() { function detectCurrentStudentId() {
const match = window.location.pathname.match(/\/students\/([a-zA-Z0-9-]+)/); // 1. Check URL pathname & search params
if (match && match[1]) { const pathMatch = window.location.pathname.match(/\/(?:students|children|profiles)\/([a-zA-Z0-9-]+)/);
chrome.storage.local.set({ lastActiveStudentId: match[1] }); if (pathMatch && pathMatch[1]) {
return match[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; return null;
} }
@@ -28,6 +70,118 @@
} }
}).observe(document, { subtree: true, childList: true }); }).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 // Listen for messages from extension popup or dashboard
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'GET_PAGE_CONTEXT') { if (message.action === 'GET_PAGE_CONTEXT') {
@@ -40,49 +194,137 @@
return true; 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') { if (message.action === 'INSPECT_LIVE_FEED') {
(async () => { (async () => {
try { try {
const studentId = message.studentId || detectCurrentStudentId(); const studentId = message.studentId || detectCurrentStudentId();
const apiListRes = await fetch(`https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities?page_size=25`, { const endpointProbes = {};
credentials: 'include',
headers: { 'Accept': 'application/json' }
}).then(r => r.json()).catch(e => ({ error: e.message }));
const rawActs = apiListRes?.activities || apiListRes?.items || []; async function probe(name, url) {
try {
// Sample detail fetches for video, nap, checkin const res = await fetch(url, {
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}`, {
credentials: 'include', credentials: 'include',
headers: { 'Accept': 'application/json' } headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
}).then(r => r.json()).catch(e => ({ error: e.message })); });
sampleDetails[type] = detail; 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 domCards = scrapeDomFeedCards();
const reactFiberActivities = extractActivitiesFromReactFiber();
sendResponse({ sendResponse({
success: true, success: true,
studentId, studentId,
apiSampleActivities: rawActs.slice(0, 10), currentUrl: window.location.href,
sampleDetails,
domCards: domCards.slice(0, 20),
pageTitle: document.title, pageTitle: document.title,
currentUrl: window.location.href endpointProbes,
domCards: domCards.slice(0, 20),
reactFiberCount: reactFiberActivities.length,
reactFiberSample: reactFiberActivities.slice(0, 5)
}); });
} catch (err) { } catch (err) {
sendResponse({ success: false, error: err.message }); 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 // DOM Scraper for Brightwheel Web Feed
function scrapeDomFeedCards() { function scrapeDomFeedCards() {
const results = []; 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