Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae39f52563 | ||
|
|
0e23532113 | ||
|
|
fb73590c3e | ||
|
|
7c48f3c0b0 |
@@ -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
@@ -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,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
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ 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
|
||||||
|
|
||||||
@@ -19,8 +19,20 @@ 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 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"
|
@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:
|
||||||
@echo "🔍 Validating manifest.json and JavaScript files..."
|
@echo "🔍 Validating manifest.json and JavaScript files..."
|
||||||
@@ -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" \
|
||||||
|
|||||||
+6
-5
@@ -1,8 +1,8 @@
|
|||||||
# 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+194
-78
@@ -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,
|
||||||
@@ -78,7 +78,8 @@ 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')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
try {
|
||||||
// 1. Try to fetch user/guardian profile to list students
|
const [bwTab] = await chrome.tabs.query({ url: 'https://schools.mybrightwheel.com/*' });
|
||||||
const userRes = await fetchBrightwheelAPI('https://schools.mybrightwheel.com/api/v1/users/me');
|
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) {
|
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;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Default student card with manual ID
|
|
||||||
el.sessionDot.classList.add('online');
|
el.sessionDot.classList.add('online');
|
||||||
el.sessionStatusText.textContent = 'Session Connected';
|
el.sessionStatusText.textContent = 'Session Connected';
|
||||||
state.students = [{
|
state.students = [{
|
||||||
id: state.selectedStudentId,
|
id: state.selectedStudentId,
|
||||||
first_name: 'Student',
|
first_name: 'Student',
|
||||||
last_name: `(${state.selectedStudentId.substring(0, 8)}...)`,
|
last_name: `(${state.selectedStudentId.substring(0, 8)}...)`,
|
||||||
school_name: 'Brightwheel School'
|
school_name: 'Brightwheel Account'
|
||||||
}];
|
}];
|
||||||
renderStudentsList();
|
renderStudentsList();
|
||||||
log(`Using student UUID: ${state.selectedStudentId}`, 'info');
|
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) {
|
} catch (err) {
|
||||||
log(`Session check note: ${err.message}. Ready with manual ID.`, 'warn');
|
log(`Session check note: ${err.message}.`, 'warn');
|
||||||
renderStudentsList();
|
renderStudentsList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,7 +304,7 @@ 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,18 +672,21 @@ async function fetchAllActivities(studentId) {
|
|||||||
// Non-critical
|
// 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 (hasMore && !state.isCancelled) {
|
||||||
while (state.isPaused && !state.isCancelled) {
|
while (state.isPaused && !state.isCancelled) {
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
}
|
}
|
||||||
if (state.isCancelled) break;
|
if (state.isCancelled) break;
|
||||||
|
|
||||||
// Build URL query
|
let url = `${endpointBaseUrl}${endpointBaseUrl.includes('?') ? '&' : '?'}page_size=${pageSize}&page=${page}`;
|
||||||
let url = `https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities?page_size=${pageSize}&page=${page}`;
|
if (startDate) url += `&start_date=${encodeURIComponent(startDate)}`;
|
||||||
if (startDate) url += `&start_date=${startDate}`;
|
if (endDate) url += `&end_date=${encodeURIComponent(endDate)}`;
|
||||||
if (endDate) url += `&end_date=${endDate}`;
|
|
||||||
|
|
||||||
log(`Fetching activities page ${page}...`, 'info');
|
|
||||||
|
|
||||||
let response = null;
|
let response = null;
|
||||||
let retries = 0;
|
let retries = 0;
|
||||||
@@ -610,27 +707,21 @@ async function fetchAllActivities(studentId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response || !response.success || !response.data) {
|
const rawActivities = response?.data?.activities || response?.data?.items || (Array.isArray(response?.data) ? 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 || [];
|
|
||||||
|
|
||||||
if (!Array.isArray(rawActivities) || rawActivities.length === 0) {
|
if (!Array.isArray(rawActivities) || rawActivities.length === 0) {
|
||||||
log(`No more activities found after page ${page - 1}. Done harvesting.`, 'info');
|
|
||||||
hasMore = false;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter and normalize activities
|
|
||||||
for (const act of rawActivities) {
|
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
|
// Schema inspection for debugging
|
||||||
const actType = act.activity_type || act.action_type || act.type || 'unknown';
|
const actType = act.activity_type || act.action_type || act.type || 'unknown';
|
||||||
if (!seenTypes.has(actType)) {
|
if (!seenTypes.has(actType)) {
|
||||||
@@ -640,13 +731,16 @@ async function fetchAllActivities(studentId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If video activity has no immediate URL, fetch single activity detail
|
// 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 {
|
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) {
|
if (detailRes && detailRes.success && detailRes.data) {
|
||||||
const detailObj = detailRes.data.activity || detailRes.data;
|
const detailObj = detailRes.data.activity || detailRes.data;
|
||||||
Object.assign(act, detailObj);
|
Object.assign(act, detailObj);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Continue
|
// Continue
|
||||||
}
|
}
|
||||||
@@ -659,7 +753,7 @@ async function fetchAllActivities(studentId) {
|
|||||||
|
|
||||||
state.stats.activitiesCount = state.activities.length;
|
state.stats.activitiesCount = state.activities.length;
|
||||||
updateMetrics();
|
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) {
|
if (rawActivities.length < pageSize) {
|
||||||
hasMore = false;
|
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) {
|
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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 = [];
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
Reference in New Issue
Block a user