Compare commits
3
Commits
v1.0.0
...
0e23532113
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e23532113 | ||
|
|
fb73590c3e | ||
|
|
7c48f3c0b0 |
@@ -29,12 +29,13 @@ jobs:
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./manifest.json').version")
|
||||
echo "EXT_VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "ext_version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Building extension release for version: $VERSION"
|
||||
|
||||
- name: Build Production Extension ZIP
|
||||
id: package
|
||||
run: |
|
||||
ZIP_NAME="brightwheel-exporter-v${{ env.EXT_VERSION }}.zip"
|
||||
ZIP_NAME="brightwheel-exporter-v${{ steps.version.outputs.ext_version }}.zip"
|
||||
echo "📦 Packaging $ZIP_NAME..."
|
||||
zip -r "$ZIP_NAME" . \
|
||||
-x "*.git*" \
|
||||
@@ -49,13 +50,14 @@ jobs:
|
||||
-x "LICENSE" \
|
||||
-x "*.zip"
|
||||
|
||||
echo "ZIP_FILE=$ZIP_NAME" >> $GITHUB_ENV
|
||||
echo "zip_file=$ZIP_NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Package contents verified:"
|
||||
unzip -l "$ZIP_NAME"
|
||||
|
||||
- name: Upload ZIP to GitHub Release Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ env.ZIP_FILE }}
|
||||
files: ${{ steps.package.outputs.zip_file }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 CoderSherlock
|
||||
Copyright (c) 2026 Pengzhan Hao
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -10,7 +10,7 @@ DIST_DIR := dist
|
||||
ZIP_NAME := brightwheel-exporter-v$(VERSION).zip
|
||||
ZIP_PATH := $(DIST_DIR)/$(ZIP_NAME)
|
||||
|
||||
.PHONY: all build zip check clean help
|
||||
.PHONY: all build zip check clean help analyze-naps analyze-attendance
|
||||
|
||||
all: build
|
||||
|
||||
@@ -19,8 +19,20 @@ help:
|
||||
@echo "Available commands:"
|
||||
@echo " make build (or make zip) - Build the Chrome Web Store production ZIP package"
|
||||
@echo " make check - Verify JavaScript syntax and manifest validity"
|
||||
@echo " make analyze-naps - Run nap analysis (e.g. make analyze-naps INPUT=path/to/export)"
|
||||
@echo " make analyze-attendance - Run attendance analysis (e.g. make analyze-attendance INPUT=path/to/export)"
|
||||
@echo " make clean - Remove the dist directory and build artifacts"
|
||||
|
||||
## analyze-naps: Run the nap & sleep data analyzer on any exported dataset
|
||||
INPUT ?= exports/
|
||||
OUTDIR ?= $(INPUT)
|
||||
analyze-naps:
|
||||
@python3 tools/analyze_naps.py --input $(INPUT) --outdir $(OUTDIR)
|
||||
|
||||
## analyze-attendance: Run the attendance & daily schedule analyzer on any exported dataset
|
||||
analyze-attendance:
|
||||
@python3 tools/analyze_attendance.py --input $(INPUT) --outdir $(OUTDIR)
|
||||
|
||||
## check: Verify JavaScript syntax and manifest validity
|
||||
check:
|
||||
@echo "🔍 Validating manifest.json and JavaScript files..."
|
||||
@@ -40,10 +52,11 @@ build: check
|
||||
-x ".githooks/*" \
|
||||
-x ".github/*" \
|
||||
-x "$(DIST_DIR)/*" \
|
||||
-x "emmy/*" \
|
||||
-x "tools/*" \
|
||||
-x "exports/*" \
|
||||
-x "data/*" \
|
||||
-x "test_data/*" \
|
||||
-x "*_export/*" \
|
||||
-x "screenshots/*" \
|
||||
-x "Makefile" \
|
||||
-x "*.md" \
|
||||
|
||||
+6
-5
@@ -1,8 +1,8 @@
|
||||
# Privacy Policy for Brightwheel Memories & Reports Exporter
|
||||
|
||||
**Last Updated:** August 23, 2026
|
||||
**Developer:** CoderSherlock
|
||||
**Repository:** [https://github.com/CoderSherlock/brightwheel-exporter](https://github.com/CoderSherlock/brightwheel-exporter)
|
||||
**Developer:** Pengzhan Hao
|
||||
**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
|
||||
|
||||
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:
|
||||
- **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)
|
||||
Because this project is open source under the MIT License, you can inspect the full source code and verify our privacy practices directly in the Git repository:
|
||||
|
||||
- **Git Repository:** [https://git.pengzhan.dev/haopengzhan/brightwheel-exporter](https://git.pengzhan.dev/haopengzhan/brightwheel-exporter)
|
||||
- **Report Issues / Inquiries:** [https://git.pengzhan.dev/haopengzhan/brightwheel-exporter/issues](https://git.pengzhan.dev/haopengzhan/brightwheel-exporter/issues)
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
### 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.
|
||||
3. Open Google Chrome and navigate to:
|
||||
|
||||
@@ -38,13 +38,13 @@
|
||||
|
||||
4. Enable **Developer mode** using the toggle switch in the top-right 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!
|
||||
|
||||
### Option B: Clone via Git (For Developers)
|
||||
|
||||
```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.
|
||||
|
||||
@@ -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