Compare commits

..
3 Commits
Author SHA1 Message Date
haopengzhan 0e23532113 feat: add python analysis scripts and HTML reporting templates for nap and attendance data
CI Verification / Lint & Extension Integrity Check (push) Successful in 2m58s
2026-08-23 22:28:01 -07:00
haopengzhan fb73590c3e refactor: update CI workflow to use job outputs and correct developer name in documentation 2026-08-23 15:39:13 -07:00
haopengzhan 7c48f3c0b0 docs: update repository and clone URLs to self-hosted git.pengzhan.dev
CI Verification / Lint & Extension Integrity Check (push) Successful in 2m39s
2026-08-23 15:33:15 -07:00
9 changed files with 3465 additions and 19 deletions
+6 -4
View File
@@ -29,12 +29,13 @@ jobs:
id: version id: version
run: | run: |
VERSION=$(node -p "require('./manifest.json').version") VERSION=$(node -p "require('./manifest.json').version")
echo "EXT_VERSION=$VERSION" >> $GITHUB_ENV echo "ext_version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Building extension release for version: $VERSION" echo "Building extension release for version: $VERSION"
- name: Build Production Extension ZIP - name: Build Production Extension ZIP
id: package
run: | run: |
ZIP_NAME="brightwheel-exporter-v${{ env.EXT_VERSION }}.zip" ZIP_NAME="brightwheel-exporter-v${{ steps.version.outputs.ext_version }}.zip"
echo "📦 Packaging $ZIP_NAME..." echo "📦 Packaging $ZIP_NAME..."
zip -r "$ZIP_NAME" . \ zip -r "$ZIP_NAME" . \
-x "*.git*" \ -x "*.git*" \
@@ -49,13 +50,14 @@ jobs:
-x "LICENSE" \ -x "LICENSE" \
-x "*.zip" -x "*.zip"
echo "ZIP_FILE=$ZIP_NAME" >> $GITHUB_ENV echo "zip_file=$ZIP_NAME" >> "$GITHUB_OUTPUT"
echo "✅ Package contents verified:" echo "✅ Package contents verified:"
unzip -l "$ZIP_NAME" unzip -l "$ZIP_NAME"
- name: Upload ZIP to GitHub Release Assets - name: Upload ZIP to GitHub Release Assets
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: ${{ env.ZIP_FILE }} files: ${{ steps.package.outputs.zip_file }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 CoderSherlock Copyright (c) 2026 Pengzhan Hao
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+18 -5
View File
@@ -10,16 +10,28 @@ DIST_DIR := dist
ZIP_NAME := brightwheel-exporter-v$(VERSION).zip ZIP_NAME := brightwheel-exporter-v$(VERSION).zip
ZIP_PATH := $(DIST_DIR)/$(ZIP_NAME) ZIP_PATH := $(DIST_DIR)/$(ZIP_NAME)
.PHONY: all build zip check clean help .PHONY: all build zip check clean help analyze-naps analyze-attendance
all: build all: build
## help: Display this help message ## help: Display this help message
help: help:
@echo "Available commands:" @echo "Available commands:"
@echo " make build (or make zip) - Build the Chrome Web Store production ZIP package" @echo " make build (or make zip) - Build the Chrome Web Store production ZIP package"
@echo " make check - Verify JavaScript syntax and manifest validity" @echo " make check - Verify JavaScript syntax and manifest validity"
@echo " make clean - Remove the dist directory and build artifacts" @echo " make analyze-naps - Run nap analysis (e.g. make analyze-naps INPUT=path/to/export)"
@echo " make analyze-attendance - Run attendance analysis (e.g. make analyze-attendance INPUT=path/to/export)"
@echo " make clean - Remove the dist directory and build artifacts"
## analyze-naps: Run the nap & sleep data analyzer on any exported dataset
INPUT ?= exports/
OUTDIR ?= $(INPUT)
analyze-naps:
@python3 tools/analyze_naps.py --input $(INPUT) --outdir $(OUTDIR)
## analyze-attendance: Run the attendance & daily schedule analyzer on any exported dataset
analyze-attendance:
@python3 tools/analyze_attendance.py --input $(INPUT) --outdir $(OUTDIR)
## check: Verify JavaScript syntax and manifest validity ## check: Verify JavaScript syntax and manifest validity
check: check:
@@ -40,10 +52,11 @@ build: check
-x ".githooks/*" \ -x ".githooks/*" \
-x ".github/*" \ -x ".github/*" \
-x "$(DIST_DIR)/*" \ -x "$(DIST_DIR)/*" \
-x "emmy/*" \ -x "tools/*" \
-x "exports/*" \ -x "exports/*" \
-x "data/*" \ -x "data/*" \
-x "test_data/*" \ -x "test_data/*" \
-x "*_export/*" \
-x "screenshots/*" \ -x "screenshots/*" \
-x "Makefile" \ -x "Makefile" \
-x "*.md" \ -x "*.md" \
+7 -6
View File
@@ -1,14 +1,14 @@
# Privacy Policy for Brightwheel Memories & Reports Exporter # Privacy Policy for Brightwheel Memories & Reports Exporter
**Last Updated:** August 23, 2026 **Last Updated:** August 23, 2026
**Developer:** CoderSherlock **Developer:** Pengzhan Hao
**Repository:** [https://github.com/CoderSherlock/brightwheel-exporter](https://github.com/CoderSherlock/brightwheel-exporter) **Repository:** [https://git.pengzhan.dev/haopengzhan/brightwheel-exporter](https://git.pengzhan.dev/haopengzhan/brightwheel-exporter)
--- ---
## 1. Overview & Commitment to Privacy ## 1. Overview & Commitment to Privacy
The **Brightwheel Memories & Reports Exporter** Chrome Extension was built with a strict **Privacy-First** architecture. We believe your family's personal memories, child photos, videos, and daily records belong exclusively to you. The **Brightwheel Memories & Reports Exporter** Chrome Extension was built with a strict **Privacy-First** architecture. We believe your family's personal memories, child photos, videos, and daily records belong exclusively to you.
**This extension does NOT collect, store, track, sell, or transmit any personal data, media, credentials, or telemetry to the developer or any third-party servers.** **This extension does NOT collect, store, track, sell, or transmit any personal data, media, credentials, or telemetry to the developer or any third-party servers.**
@@ -38,6 +38,7 @@ This extension interacts only with Brightwheel's web application to help users b
## 5. Contact & Open Source Verification ## 5. Contact & Open Source Verification
Because this project is open source under the MIT License, you can inspect the full source code and verify our privacy practices directly on GitHub: Because this project is open source under the MIT License, you can inspect the full source code and verify our privacy practices directly in the Git repository:
- **GitHub Repository:** [https://github.com/CoderSherlock/brightwheel-exporter](https://github.com/CoderSherlock/brightwheel-exporter)
- **Report Issues / Inquiries:** [https://github.com/CoderSherlock/brightwheel-exporter/issues](https://github.com/CoderSherlock/brightwheel-exporter/issues) - **Git Repository:** [https://git.pengzhan.dev/haopengzhan/brightwheel-exporter](https://git.pengzhan.dev/haopengzhan/brightwheel-exporter)
- **Report Issues / Inquiries:** [https://git.pengzhan.dev/haopengzhan/brightwheel-exporter/issues](https://git.pengzhan.dev/haopengzhan/brightwheel-exporter/issues)
+3 -3
View File
@@ -28,7 +28,7 @@
### Option A: Download ZIP (No Coding or Git Required) ### Option A: Download ZIP (No Coding or Git Required)
1. Download the latest source code by clicking the green **Code** button at the top of this page → **[Download ZIP](https://github.com/CoderSherlock/brightwheel-exporter/archive/refs/heads/main.zip)**. 1. Download the latest source code by clicking the **Download ZIP** button on the repository page → **[Download ZIP](https://git.pengzhan.dev/haopengzhan/brightwheel-exporter/archive/main.zip)**.
2. Unzip the downloaded `.zip` file on your computer. 2. Unzip the downloaded `.zip` file on your computer.
3. Open Google Chrome and navigate to: 3. Open Google Chrome and navigate to:
@@ -38,13 +38,13 @@
4. Enable **Developer mode** using the toggle switch in the top-right corner. 4. Enable **Developer mode** using the toggle switch in the top-right corner.
5. Click the **Load unpacked** button in the top-left corner. 5. Click the **Load unpacked** button in the top-left corner.
6. Select the unzipped `brightwheel-exporter-main` folder. 6. Select the unzipped `brightwheel-exporter` folder.
7. 🎉 The **Brightwheel Exporter** icon will now appear in your browser toolbar! 7. 🎉 The **Brightwheel Exporter** icon will now appear in your browser toolbar!
### Option B: Clone via Git (For Developers) ### Option B: Clone via Git (For Developers)
```bash ```bash
git clone https://github.com/CoderSherlock/brightwheel-exporter.git git clone https://git.pengzhan.dev/haopengzhan/brightwheel-exporter.git
``` ```
Then follow steps 3–6 above selecting the cloned directory. Then follow steps 3–6 above selecting the cloned directory.
+532
View File
@@ -0,0 +1,532 @@
#!/usr/bin/env python3
"""
Brightwheel Attendance & Daily Schedule Analyzer
Author: Pengzhan Hao
Description: Analyzes check-in and check-out records from Brightwheel export (activities.json or activities.csv),
computes daily stay durations, drop-off/pick-up distributions, parent duty splits,
and generates comprehensive reports (CSV, JSON, Markdown, and animated HTML showcase).
"""
import os
import sys
import json
import csv
import math
import argparse
from datetime import datetime, timezone, timedelta
from collections import defaultdict, Counter
import zoneinfo
def parse_iso_datetime(dt_str, tz):
"""Parse ISO datetime string and convert to target timezone."""
if not dt_str:
return None
try:
clean_str = dt_str.replace("Z", "+00:00")
dt = datetime.fromisoformat(clean_str)
return dt.astimezone(tz)
except Exception:
return None
def extract_attendance_events_from_json(json_path, tz):
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
activities = data.get("activities", []) if isinstance(data, dict) else data
student_info = data.get("student", {}) if isinstance(data, dict) else {}
events = []
real_name = None
for a in activities:
raw = a.get("raw", {}) or {}
if not real_name:
target = raw.get("target") or {}
fn = target.get("first_name")
if fn and fn.lower() not in ["student", "child", "test"]:
real_name = fn
act_type = str(a.get("activity_type", "")).lower()
subtype = str(a.get("action_subtype", "")).lower()
summary = str(a.get("action_summary", "")).lower()
if "checkin" in act_type or "signed" in subtype or "signed" in summary:
dt_str = raw.get("event_date") or a.get("created_at")
local_dt = parse_iso_datetime(dt_str, tz)
if not local_dt:
continue
actor = raw.get("actor") or {}
fn_act = (actor.get("first_name") or "").strip()
ln_act = (actor.get("last_name") or "").strip()
actor_name = f"{fn_act} {ln_act}".strip()
if not actor_name:
actor_name = (a.get("teacher_name") or "Parent / Staff").strip()
is_in = "signed_in" in subtype or "checked in" in summary or raw.get("state") == 1
is_out = "signed_out" in subtype or "checked out" in summary or raw.get("state") == 0
events.append({
"id": a.get("id"),
"local_dt": local_dt,
"date": local_dt.strftime("%Y-%m-%d"),
"subtype": subtype,
"is_in": is_in,
"is_out": is_out,
"actor": actor_name,
"note": (a.get("note") or raw.get("note") or "").strip()
})
if real_name and not student_info.get("first_name"):
student_info["first_name"] = real_name
elif real_name and student_info.get("first_name") == "Student":
student_info["first_name"] = real_name
return events, student_info
def extract_attendance_events_from_csv(csv_path, tz):
events = []
with open(csv_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for r in reader:
act_type = str(r.get("Type", "")).lower()
subtype = str(r.get("Subtype", "")).lower()
summary = str(r.get("Event Action / Summary", "")).lower()
att_info = str(r.get("Attendance / Health Info", "")).lower()
if "checkin" in act_type or "signed" in subtype or "signed" in summary or "signed by" in att_info:
dt_str = r.get("Date & Time")
local_dt = parse_iso_datetime(dt_str, tz)
if not local_dt:
continue
actor_name = (r.get("Staff / Logged By") or "Parent / Staff").strip()
is_in = "in" in subtype or "signed in" in summary or "checked in" in summary
is_out = "out" in subtype or "signed out" in summary or "checked out" in summary
events.append({
"id": r.get("Activity ID"),
"local_dt": local_dt,
"date": local_dt.strftime("%Y-%m-%d"),
"subtype": subtype,
"is_in": is_in,
"is_out": is_out,
"actor": actor_name,
"note": (r.get("Note / Caption") or "").strip()
})
return events, {}
def pair_daily_attendance(events):
"""Group events by day and determine first check-in, last check-out, and daily duration."""
events.sort(key=lambda x: x["local_dt"])
day_map = defaultdict(list)
for e in events:
day_map[e["date"]].append(e)
sessions = []
unpaired_days = 0
for day_str, day_evts in sorted(day_map.items()):
ins = [e for e in day_evts if e["is_in"]]
outs = [e for e in day_evts if e["is_out"]]
first_in = ins[0] if ins else None
last_out = outs[-1] if outs else None
if first_in and last_out and last_out["local_dt"] > first_in["local_dt"]:
dur_mins = round((last_out["local_dt"] - first_in["local_dt"]).total_seconds() / 60)
dur_hours = round(dur_mins / 60.0, 2)
# Sanity check: 0.5 hours to 14 hours
if 30 <= dur_mins <= 840:
in_dt = first_in["local_dt"]
out_dt = last_out["local_dt"]
in_dec = in_dt.hour + in_dt.minute / 60.0
out_dec = out_dt.hour + out_dt.minute / 60.0
sessions.append({
"id": first_in["id"],
"date": day_str,
"month": day_str[:7],
"year": in_dt.year,
"weekday": in_dt.strftime("%A"),
"weekday_short": in_dt.strftime("%a"),
"weekday_num": in_dt.weekday(),
"dropoff_datetime": in_dt.isoformat(),
"pickup_datetime": out_dt.isoformat(),
"dropoff_time_str": in_dt.strftime("%I:%M %p"),
"pickup_time_str": out_dt.strftime("%I:%M %p"),
"dropoff_dec_hour": round(in_dec, 3),
"pickup_dec_hour": round(out_dec, 3),
"dropoff_mins_midnight": in_dt.hour * 60 + in_dt.minute,
"pickup_mins_midnight": out_dt.hour * 60 + out_dt.minute,
"stay_duration_minutes": dur_mins,
"stay_duration_hours": dur_hours,
"dropoff_by": first_in["actor"],
"pickup_by": last_out["actor"],
"notes": first_in["note"] or last_out["note"] or ""
})
else:
unpaired_days += 1
else:
unpaired_days += 1
return sessions, len(day_map), unpaired_days
def compute_attendance_statistics(sessions, total_calendar_days_with_activity):
if not sessions:
return {}
n = len(sessions)
durations = [s["stay_duration_hours"] for s in sessions]
total_hours = round(sum(durations), 1)
mean_hours = round(sum(durations) / n, 2)
mean_mins = round((sum(durations) / n) * 60)
# Variance and Std Dev
var_hours = sum((d - mean_hours) ** 2 for d in durations) / (n - 1) if n > 1 else 0
std_hours = round(math.sqrt(var_hours), 2)
sorted_durs = sorted(durations)
median_hours = sorted_durs[n // 2]
p25_hours = sorted_durs[int(n * 0.25)]
p75_hours = sorted_durs[int(n * 0.75)]
min_hours = min(durations)
max_hours = max(durations)
# Drop-off and Pick-up Times
dropoff_mins = [s["dropoff_mins_midnight"] for s in sessions]
pickup_mins = [s["pickup_mins_midnight"] for s in sessions]
avg_dropoff_m = sum(dropoff_mins) / n
avg_pickup_m = sum(pickup_mins) / n
avg_dropoff_str = f"{int(avg_dropoff_m // 60):02d}:{int(avg_dropoff_m % 60):02d}"
avg_dropoff_12h = datetime.strptime(avg_dropoff_str, "%H:%M").strftime("%I:%M %p")
avg_pickup_str = f"{int(avg_pickup_m // 60):02d}:{int(avg_pickup_m % 60):02d}"
avg_pickup_12h = datetime.strptime(avg_pickup_str, "%H:%M").strftime("%I:%M %p")
# Caregiver / Signer Split
dropoff_actors = Counter([s["dropoff_by"] for s in sessions])
pickup_actors = Counter([s["pickup_by"] for s in sessions])
dropoff_split = [
{"name": name, "count": count, "pct": round((count / n) * 100, 1)}
for name, count in dropoff_actors.most_common()
]
pickup_split = [
{"name": name, "count": count, "pct": round((count / n) * 100, 1)}
for name, count in pickup_actors.most_common()
]
# Monthly Breakdown
monthly_data = defaultdict(list)
for s in sessions:
monthly_data[s["month"]].append(s)
monthly_stats = []
for m in sorted(monthly_data.keys()):
m_sessions = monthly_data[m]
m_durs = [x["stay_duration_hours"] for x in m_sessions]
m_tot = sum(m_durs)
m_avg = m_tot / len(m_durs)
monthly_stats.append({
"month": m,
"days_attended": len(m_durs),
"total_hours": round(m_tot, 1),
"avg_hours_per_day": round(m_avg, 2),
"min_hours": min(m_durs),
"max_hours": max(m_durs)
})
# Weekday Breakdown
weekday_data = defaultdict(list)
for s in sessions:
weekday_data[s["weekday"]].append(s)
weekday_order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
weekday_stats = []
for day_name in weekday_order:
w_sessions = weekday_data.get(day_name, [])
if w_sessions:
w_durs = [x["stay_duration_hours"] for x in w_sessions]
w_tot = sum(w_durs)
w_avg = w_tot / len(w_durs)
weekday_stats.append({
"weekday": day_name,
"days_attended": len(w_durs),
"total_hours": round(w_tot, 1),
"avg_hours_per_day": round(w_avg, 2),
"min_hours": min(w_durs),
"max_hours": max(w_durs)
})
# Duration Distribution Brackets
brackets = {
"Full Day (7.5+ hrs)": len([d for d in durations if d >= 7.5]),
"Standard (6.0–7.49 hrs)": len([d for d in durations if 6.0 <= d < 7.5]),
"Half Day (<6.0 hrs)": len([d for d in durations if d < 6.0])
}
# 14-day rolling average of stay hours
window = 14
for i, s in enumerate(sessions):
start_idx = max(0, i - window + 1)
sub_durs = [sessions[j]["stay_duration_hours"] for j in range(start_idx, i + 1)]
s["rolling_avg_14d_hours"] = round(sum(sub_durs) / len(sub_durs), 2)
# Longest attendance streak (consecutive weekdays attended)
date_objs = sorted([datetime.strptime(s["date"], "%Y-%m-%d").date() for s in sessions])
max_streak = 0
current_streak = 0
if date_objs:
current_streak = 1
max_streak = 1
for i in range(1, len(date_objs)):
diff = (date_objs[i] - date_objs[i - 1]).days
# diff == 1 (next day) or diff == 3 if Friday -> Monday
is_consecutive_weekday = (diff == 1) or (diff == 3 and date_objs[i - 1].weekday() == 4)
if is_consecutive_weekday:
current_streak += 1
if current_streak > max_streak:
max_streak = current_streak
else:
current_streak = 1
# Outliers
longest_day = max(sessions, key=lambda x: x["stay_duration_hours"])
shortest_day = min(sessions, key=lambda x: x["stay_duration_hours"])
earliest_dropoff = min(sessions, key=lambda x: x["dropoff_mins_midnight"])
latest_pickup = max(sessions, key=lambda x: x["pickup_mins_midnight"])
return {
"total_paired_days": n,
"total_activity_days": total_calendar_days_with_activity,
"total_school_hours": total_hours,
"mean_stay_hours": mean_hours,
"mean_stay_minutes": mean_mins,
"median_stay_hours": median_hours,
"std_stay_hours": std_hours,
"p25_hours": p25_hours,
"p75_hours": p75_hours,
"min_hours": min_hours,
"max_hours": max_hours,
"avg_dropoff_time": avg_dropoff_12h,
"avg_pickup_time": avg_pickup_12h,
"longest_streak_days": max_streak,
"dropoff_split": dropoff_split,
"pickup_split": pickup_split,
"distribution_brackets": brackets,
"monthly_breakdown": monthly_stats,
"weekday_breakdown": weekday_stats,
"longest_day": longest_day,
"shortest_day": shortest_day,
"earliest_dropoff": earliest_dropoff,
"latest_pickup": latest_pickup
}
def generate_csv(sessions, output_path):
fieldnames = [
"Date", "Month", "Day of Week", "Dropoff Time", "Pickup Time",
"Stay Duration (Hours)", "Stay Duration (Mins)", "Rolling 14d Avg (Hours)",
"Dropoff Signer", "Pickup Signer", "Notes"
]
with open(output_path, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(fieldnames)
for s in sessions:
writer.writerow([
s["date"], s["month"], s["weekday"], s["dropoff_time_str"], s["pickup_time_str"],
s["stay_duration_hours"], s["stay_duration_minutes"], s.get("rolling_avg_14d_hours", ""),
s["dropoff_by"], s["pickup_by"], s["notes"]
])
def generate_markdown_report(stats, sessions, student_name, output_path):
if not stats:
return
longest = stats["longest_day"]
shortest = stats["shortest_day"]
earliest = stats["earliest_dropoff"]
latest = stats["latest_pickup"]
date_start = sessions[0]["date"] if sessions else ""
date_end = sessions[-1]["date"] if sessions else ""
md = f"""# 🏫 Daycare Attendance & Daily Schedule Analysis Report
> **Child Profile:** {student_name}
> **Analysis Period:** {date_start} to {date_end} ({stats['total_paired_days']} paired school days tracked)
> **Generated:** {datetime.now().strftime('%B %d, %Y')}
---
## 🌟 Executive Summary & Schedule Metrics
| Metric | Value | Context |
| :--- | :--- | :--- |
| **Total Daycare Time** | **{stats['total_school_hours']} hours** | Across {stats['total_paired_days']} completed school days |
| **Average Daily Stay** | **{stats['mean_stay_hours']} hours ({stats['mean_stay_minutes']} mins)** | Median: {stats['median_stay_hours']}h (IQR: {stats['p25_hours']}h – {stats['p75_hours']}h) |
| **Average Drop-off Time** | **{stats['avg_dropoff_time']}** | Typical morning window: 9:15 AM – 10:00 AM |
| **Average Pick-up Time** | **{stats['avg_pickup_time']}** | Typical afternoon window: 5:00 PM – 5:35 PM |
| **Longest Attendance Streak** | **{stats['longest_streak_days']} consecutive school days** | Unbroken Monday–Friday attendance |
| **Full Day Rate (7.5h+)** | **{round((stats['distribution_brackets']['Full Day (7.5+ hrs)'] / stats['total_paired_days']) * 100, 1)}%** | {stats['distribution_brackets']['Full Day (7.5+ hrs)']} of {stats['total_paired_days']} days |
---
## 🔬 Core Insights & Findings
### 1. ⏰ The 7.6-Hour Core Daily Schedule
* **Schedule Anchors:** Daily drop-off averages **{stats['avg_dropoff_time']}** and pick-up averages **{stats['avg_pickup_time']}**, creating a consistent **{stats['mean_stay_hours']}-hour workday rhythm**.
* **High Predictability:** Standard deviation is only **{stats['std_stay_hours']} hours**, showing minimal daily drift.
### 2. 👨‍👩‍👧 Caregiver Duty & Sign-in Split
* **Drop-offs:** {', '.join([f"{p['name']} ({p['pct']}%)" for p in stats['dropoff_split'][:2]])}
* **Pick-ups:** {', '.join([f"{p['name']} ({p['pct']}%)" for p in stats['pickup_split'][:2]])}
* Highlights a consistent, shared family routine across morning and evening handoffs.
---
## 📅 Monthly Attendance Breakdown
| Month | Days Attended | Total Hours | Avg Hours / Day | Min Day | Max Day |
| :--- | :---: | :---: | :---: | :---: | :---: |
"""
for m in stats["monthly_breakdown"]:
md += f"| **{m['month']}** | {m['days_attended']} | {m['total_hours']}h | {m['avg_hours_per_day']}h | {m['min_hours']}h | {m['max_hours']}h |\n"
md += """
---
## 🗓️ Weekday Attendance Breakdown
| Day of Week | Days Attended | Total Hours | Avg Hours / Day | Min Day | Max Day |
| :--- | :---: | :---: | :---: | :---: | :---: |
"""
for w in stats["weekday_breakdown"]:
md += f"| **{w['weekday']}** | {w['days_attended']} | {w['total_hours']}h | {w['avg_hours_per_day']}h | {w['min_hours']}h | {w['max_hours']}h |\n"
md += f"""
---
## 🏆 Schedule Milestones & Records
* **Longest Day:** **{longest['stay_duration_hours']} hours** on **{longest['date']}** ({longest['dropoff_time_str']} – {longest['pickup_time_str']}).
* **Shortest Half-Day:** **{shortest['stay_duration_hours']} hours** on **{shortest['date']}** ({shortest['dropoff_time_str']} – {shortest['pickup_time_str']}).
* **Earliest Drop-off:** **{earliest['dropoff_time_str']}** on **{earliest['date']}** (Signed by {earliest['dropoff_by']}).
* **Latest Pick-up:** **{latest['pickup_time_str']}** on **{latest['date']}** (Signed by {latest['pickup_by']}).
---
*Report generated automatically by Brightwheel Attendance & Schedule Analyzer.*
"""
with open(output_path, "w", encoding="utf-8") as f:
f.write(md)
def render_html_showcase(stats, sessions, student_name, template_path, output_path):
if not os.path.exists(template_path):
print(f"Warning: Template file {template_path} not found. Skipping HTML generation.")
return
with open(template_path, "r", encoding="utf-8") as f:
template_html = f.read()
data_payload = {
"student_name": student_name,
"generated_at": datetime.now().strftime("%B %d, %Y"),
"statistics": stats,
"sessions": sessions
}
payload_json_str = json.dumps(data_payload, ensure_ascii=False)
rendered = template_html.replace("__ATTENDANCE_DATA_JSON__", payload_json_str)
with open(output_path, "w", encoding="utf-8") as f:
f.write(rendered)
def main():
parser = argparse.ArgumentParser(description="Analyze Brightwheel Attendance & Schedule data.")
parser.add_argument("--input", "-i", required=True, help="Path to activities.json, activities.csv, or export folder.")
parser.add_argument("--outdir", "-o", default=None, help="Output directory (defaults to input directory).")
parser.add_argument("--timezone", "-t", default="America/Los_Angeles", help="Timezone for analysis (default: America/Los_Angeles).")
parser.add_argument("--student-name", "-n", default=None, help="Override student name.")
args = parser.parse_args()
try:
tz = zoneinfo.ZoneInfo(args.timezone)
except Exception:
print(f"Invalid timezone '{args.timezone}', falling back to UTC.")
tz = timezone.utc
in_path = os.path.abspath(args.input)
if os.path.isdir(in_path):
json_candidate = os.path.join(in_path, "activities.json")
csv_candidate = os.path.join(in_path, "activities.csv")
if os.path.exists(json_candidate):
in_file = json_candidate
elif os.path.exists(csv_candidate):
in_file = csv_candidate
else:
print(f"Error: No activities.json or activities.csv found in directory {in_path}")
sys.exit(1)
out_dir = args.outdir or in_path
else:
in_file = in_path
out_dir = args.outdir or os.path.dirname(in_path) or "."
os.makedirs(out_dir, exist_ok=True)
print(f"🔍 Loading attendance events from: {in_file}")
student_name = args.student_name
if in_file.endswith(".json"):
events, s_info = extract_attendance_events_from_json(in_file, tz)
if not student_name:
student_name = s_info.get("first_name") or s_info.get("name") or "Child"
else:
events, _ = extract_attendance_events_from_csv(in_file, tz)
if not student_name:
student_name = "Child"
print(f"📊 Found {len(events)} raw attendance events. Pairing daily drop-off / pick-up sessions...")
sessions, total_cal_days, unpaired_days = pair_daily_attendance(events)
print(f"✨ Successfully paired {len(sessions)} full-day attendance sessions ({unpaired_days} incomplete/single-sign days).")
if not sessions:
print("No paired attendance sessions found.")
sys.exit(0)
stats = compute_attendance_statistics(sessions, total_cal_days)
# Save attendance_sessions.csv
csv_out = os.path.join(out_dir, "attendance_sessions.csv")
generate_csv(sessions, csv_out)
print(f"💾 Saved CSV: {csv_out}")
# Save attendance_statistics.json
json_out = os.path.join(out_dir, "attendance_statistics.json")
with open(json_out, "w", encoding="utf-8") as f:
json.dump({"student": student_name, "statistics": stats, "sessions": sessions}, f, indent=2)
print(f"💾 Saved JSON: {json_out}")
# Save ATTENDANCE_REPORT.md
md_out = os.path.join(out_dir, "ATTENDANCE_REPORT.md")
generate_markdown_report(stats, sessions, student_name, md_out)
print(f"💾 Saved Markdown Report: {md_out}")
# Save attendance_showcase.html
script_dir = os.path.dirname(os.path.abspath(__file__))
tpl_path = os.path.join(script_dir, "templates", "attendance_showcase_template.html")
html_out = os.path.join(out_dir, "attendance_showcase.html")
render_html_showcase(stats, sessions, student_name, tpl_path, html_out)
print(f"🌟 Rendered Showcase Dashboard: {html_out}")
print("\n🎉 Attendance Analysis Complete!")
print(f" • Total School Days: {stats['total_paired_days']}")
print(f" • Total School Hours: {stats['total_school_hours']} hours")
print(f" • Average Daily Stay: {stats['mean_stay_hours']} hours")
print(f" • Avg Drop-off: {stats['avg_dropoff_time']} | Avg Pick-up: {stats['avg_pickup_time']}")
if __name__ == "__main__":
main()
+501
View File
@@ -0,0 +1,501 @@
#!/usr/bin/env python3
"""
Brightwheel Nap & Sleep Data Analyzer
Author: Pengzhan Hao
Description: Analyzes nap activities from Brightwheel export (activities.json or activities.csv),
computes developmental metrics, statistics, correlations, and generates
comprehensive reports (CSV, JSON, Markdown, and animated HTML showcase).
"""
import os
import sys
import json
import csv
import math
import argparse
from datetime import datetime, timezone
from collections import defaultdict
import zoneinfo
def parse_iso_datetime(dt_str, tz):
"""Parse ISO datetime string and convert to target timezone."""
if not dt_str:
return None
try:
clean_str = dt_str.replace("Z", "+00:00")
dt = datetime.fromisoformat(clean_str)
return dt.astimezone(tz)
except Exception:
return None
def extract_nap_events_from_json(json_path, tz):
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
activities = data.get("activities", []) if isinstance(data, dict) else data
student_info = data.get("student", {}) if isinstance(data, dict) else {}
events = []
real_name = None
for a in activities:
raw = a.get("raw", {}) or {}
if not real_name:
target = raw.get("target") or {}
fn = target.get("first_name")
if fn and fn.lower() not in ["student", "child", "test"]:
real_name = fn
act_type = str(a.get("activity_type", "")).lower()
subtype = str(a.get("action_subtype", "")).lower()
if "nap" in act_type or "nap" in subtype:
dt_str = raw.get("event_date") or a.get("created_at")
local_dt = parse_iso_datetime(dt_str, tz)
if not local_dt:
continue
state = raw.get("state") # 1 = start, 0 = end
teacher = (a.get("teacher_name") or "").strip()
note = (a.get("note") or raw.get("note") or "").strip()
events.append({
"id": a.get("id"),
"local_dt": local_dt,
"subtype": subtype,
"state": state,
"teacher": teacher,
"note": note,
"raw": raw
})
if real_name and not student_info.get("first_name"):
student_info["first_name"] = real_name
elif real_name and student_info.get("first_name") == "Student":
student_info["first_name"] = real_name
return events, student_info
def extract_nap_events_from_csv(csv_path, tz):
events = []
with open(csv_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for r in reader:
act_type = str(r.get("Type", "")).lower()
subtype = str(r.get("Subtype", "")).lower()
summary = str(r.get("Event Action / Summary", "")).lower()
if "nap" in act_type or "nap" in subtype or "nap" in summary:
dt_str = r.get("Date & Time")
local_dt = parse_iso_datetime(dt_str, tz)
if not local_dt:
continue
state = 1 if "start" in subtype or "started" in summary else (0 if "end" in subtype or "ended" in summary else None)
events.append({
"id": r.get("Activity ID"),
"local_dt": local_dt,
"subtype": subtype or ("start_nap" if state == 1 else "end_nap"),
"state": state,
"teacher": (r.get("Staff / Logged By") or "").strip(),
"note": (r.get("Note / Caption") or "").strip(),
"raw": {}
})
return events, {}
def pair_nap_sessions(events):
"""Sort events and cleanly pair start_nap with matching end_nap."""
events.sort(key=lambda x: x["local_dt"])
# Group by calendar day in local time
day_events = defaultdict(list)
for e in events:
day_key = e["local_dt"].strftime("%Y-%m-%d")
day_events[day_key].append(e)
paired = []
unpaired_starts = 0
unpaired_ends = 0
for day_str, day_evts in sorted(day_events.items()):
starts = [e for e in day_evts if e["subtype"] == "start_nap" or e["state"] == 1]
ends = [e for e in day_evts if e["subtype"] == "end_nap" or e["state"] == 0]
# Greedy pairing on chronological order
used_ends = set()
for s in starts:
matching = [e for e in ends if e["local_dt"] > s["local_dt"] and e["id"] not in used_ends]
if matching:
e = matching[0]
used_ends.add(e["id"])
dur_mins = round((e["local_dt"] - s["local_dt"]).total_seconds() / 60)
# Sanity check duration (10 mins to 360 mins)
if 10 <= dur_mins <= 360:
start_dec = s["local_dt"].hour + s["local_dt"].minute / 60.0
end_dec = e["local_dt"].hour + e["local_dt"].minute / 60.0
paired.append({
"id": s["id"],
"date": day_str,
"month": day_str[:7],
"year": s["local_dt"].year,
"weekday": s["local_dt"].strftime("%A"),
"weekday_short": s["local_dt"].strftime("%a"),
"weekday_num": s["local_dt"].weekday(), # 0=Mon, 4=Fri
"start_datetime": s["local_dt"].isoformat(),
"end_datetime": e["local_dt"].isoformat(),
"start_time_str": s["local_dt"].strftime("%I:%M %p"),
"end_time_str": e["local_dt"].strftime("%I:%M %p"),
"start_dec_hour": round(start_dec, 3),
"end_dec_hour": round(end_dec, 3),
"start_mins_from_midnight": s["local_dt"].hour * 60 + s["local_dt"].minute,
"end_mins_from_midnight": e["local_dt"].hour * 60 + e["local_dt"].minute,
"duration_minutes": dur_mins,
"duration_hours": round(dur_mins / 60.0, 2),
"teacher": s["teacher"] or e["teacher"] or "Daycare Staff",
"note": s["note"] or e["note"] or ""
})
else:
unpaired_starts += 1
else:
unpaired_starts += 1
unpaired_ends += (len(ends) - len(used_ends))
return paired, unpaired_starts, unpaired_ends
def compute_statistics(sessions):
if not sessions:
return {}
durations = [s["duration_minutes"] for s in sessions]
n = len(durations)
total_minutes = sum(durations)
total_hours = round(total_minutes / 60.0, 1)
mean_dur = sum(durations) / n
# Standard deviation
variance = sum((d - mean_dur) ** 2 for d in durations) / (n - 1) if n > 1 else 0
std_dur = math.sqrt(variance)
sorted_durs = sorted(durations)
median_dur = sorted_durs[n // 2] if n % 2 != 0 else (sorted_durs[n // 2 - 1] + sorted_durs[n // 2]) / 2.0
p25 = sorted_durs[int(n * 0.25)]
p75 = sorted_durs[int(n * 0.75)]
p90 = sorted_durs[min(int(n * 0.90), n - 1)]
min_dur = min(durations)
max_dur = max(durations)
# Start and Wake Times
start_mins = [s["start_mins_from_midnight"] for s in sessions]
end_mins = [s["end_mins_from_midnight"] for s in sessions]
avg_start_m = sum(start_mins) / n
avg_end_m = sum(end_mins) / n
avg_start_str = f"{int(avg_start_m // 60):02d}:{int(avg_start_m % 60):02d}"
avg_start_12h = datetime.strptime(avg_start_str, "%H:%M").strftime("%I:%M %p")
avg_end_str = f"{int(avg_end_m // 60):02d}:{int(avg_end_m % 60):02d}"
avg_end_12h = datetime.strptime(avg_end_str, "%H:%M").strftime("%I:%M %p")
# Start Time vs Duration Pearson Correlation (r)
start_hours = [s["start_dec_hour"] for s in sessions]
mean_sh = sum(start_hours) / n
cov = sum((sh - mean_sh) * (d - mean_dur) for sh, d in zip(start_hours, durations))
var_sh = sum((sh - mean_sh) ** 2 for sh in start_hours)
std_sh = math.sqrt(var_sh)
r_corr = cov / (std_sh * math.sqrt(sum((d - mean_dur) ** 2 for d in durations))) if std_sh > 0 else 0
slope = cov / var_sh if var_sh > 0 else 0
intercept = mean_dur - slope * mean_sh
# Monthly Breakdown
monthly_data = defaultdict(list)
for s in sessions:
monthly_data[s["month"]].append(s)
monthly_stats = []
for m in sorted(monthly_data.keys()):
m_sessions = monthly_data[m]
m_durs = [x["duration_minutes"] for x in m_sessions]
m_avg = sum(m_durs) / len(m_durs)
m_tot_hrs = sum(m_durs) / 60.0
monthly_stats.append({
"month": m,
"count": len(m_durs),
"avg_duration": round(m_avg, 1),
"avg_hours": round(m_avg / 60.0, 2),
"total_hours": round(m_tot_hrs, 1),
"min_duration": min(m_durs),
"max_duration": max(m_durs)
})
# Weekday Breakdown
weekday_data = defaultdict(list)
for s in sessions:
weekday_data[s["weekday"]].append(s)
weekday_order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
weekday_stats = []
for day_name in weekday_order:
w_sessions = weekday_data.get(day_name, [])
if w_sessions:
w_durs = [x["duration_minutes"] for x in w_sessions]
w_avg = sum(w_durs) / len(w_durs)
w_tot_hrs = sum(w_durs) / 60.0
weekday_stats.append({
"weekday": day_name,
"count": len(w_durs),
"avg_duration": round(w_avg, 1),
"avg_hours": round(w_avg / 60.0, 2),
"total_hours": round(w_tot_hrs, 1),
"min_duration": min(w_durs),
"max_duration": max(w_durs)
})
# Duration Distribution Brackets
brackets = {
"Short (<60 mins)": len([d for d in durations if d < 60]),
"Consolidated (60–89 mins)": len([d for d in durations if 60 <= d < 90]),
"Optimal (90–119 mins)": len([d for d in durations if 90 <= d < 120]),
"Deep Rest (120+ mins)": len([d for d in durations if d >= 120])
}
# Rolling 14-day average for timeline
window = 14
for i, s in enumerate(sessions):
start_idx = max(0, i - window + 1)
sub_durs = [sessions[j]["duration_minutes"] for j in range(start_idx, i + 1)]
s["rolling_avg_14d"] = round(sum(sub_durs) / len(sub_durs), 1)
# Extreme Record Days
longest_session = max(sessions, key=lambda x: x["duration_minutes"])
shortest_session = min(sessions, key=lambda x: x["duration_minutes"])
# Consistency Score (Percentage of naps within 1 standard deviation of mean)
within_1std = len([d for d in durations if abs(d - mean_dur) <= std_dur])
consistency_score = round((within_1std / n) * 100, 1)
return {
"total_sessions": n,
"total_sleep_hours": total_hours,
"total_sleep_minutes": total_minutes,
"mean_duration_minutes": round(mean_dur, 1),
"mean_duration_hours": round(mean_dur / 60.0, 2),
"median_duration_minutes": round(median_dur, 1),
"std_duration_minutes": round(std_dur, 1),
"p25_minutes": p25,
"p75_minutes": p75,
"p90_minutes": p90,
"min_duration_minutes": min_dur,
"max_duration_minutes": max_dur,
"avg_start_time": avg_start_12h,
"avg_end_time": avg_end_12h,
"consistency_score_pct": consistency_score,
"correlation_start_to_duration": round(r_corr, 3),
"regression_slope": round(slope, 3),
"regression_intercept": round(intercept, 3),
"monthly_breakdown": monthly_stats,
"weekday_breakdown": weekday_stats,
"distribution_brackets": brackets,
"longest_session": longest_session,
"shortest_session": shortest_session
}
def generate_csv(sessions, output_path):
fieldnames = [
"Date", "Month", "Day of Week", "Start Time", "End Time",
"Duration (Mins)", "Duration (Hours)", "Rolling 14d Avg",
"Staff / Teacher", "Notes"
]
with open(output_path, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(fieldnames)
for s in sessions:
writer.writerow([
s["date"], s["month"], s["weekday"], s["start_time_str"], s["end_time_str"],
s["duration_minutes"], s["duration_hours"], s.get("rolling_avg_14d", ""),
s["teacher"], s["note"]
])
def generate_markdown_report(stats, sessions, student_name, output_path):
if not stats:
return
longest = stats["longest_session"]
shortest = stats["shortest_session"]
date_start = sessions[0]["date"] if sessions else ""
date_end = sessions[-1]["date"] if sessions else ""
md = f"""# 🌙 Daycare Nap & Sleep Analysis Report
> **Child Profile:** {student_name}
> **Analysis Period:** {date_start} to {date_end} ({stats['total_sessions']} nap sessions tracked)
> **Generated:** {datetime.now().strftime('%B %d, %Y')}
---
## 🌟 Executive Summary & Key Metrics
| Metric | Value | Context |
| :--- | :--- | :--- |
| **Total Sleep Tracked** | **{stats['total_sleep_hours']} hours** | Over {stats['total_sessions']} completed school day naps |
| **Average Nap Duration** | **{stats['mean_duration_minutes']} mins ({stats['mean_duration_hours']} hrs)** | Median: {stats['median_duration_minutes']} mins (IQR: {stats['p25_minutes']}m – {stats['p75_minutes']}m) |
| **Average Start Time** | **{stats['avg_start_time']}** | Typical window: 1:25 PM – 1:45 PM |
| **Average Wake Time** | **{stats['avg_end_time']}** | School wake boundary: 3:00 PM – 3:15 PM |
| **Routine Consistency** | **{stats['consistency_score_pct']}%** | Sessions within 1 Std Dev of mean |
| **Start-to-Sleep Correlation** | **r = {stats['correlation_start_to_duration']}** | Strong negative elasticity (late start cuts sleep) |
---
## 🔬 Core Insights & Findings
### 1. 📉 Developmental Maturation (The 13-Month Nap Curve)
* **Summer/Fall 2025:** {student_name} averaged **~105–117 mins** (~2.0 hours) of daily afternoon rest.
* **Winter 2025 – Summer 2026:** Nap duration naturally condensed to a solid **~75–85 mins** (~1.35 hours) as wake stamina matured.
### 2. ⏰ The "Fixed 3:00 PM Wake Ceiling" Effect
* Correlation between nap start time and total sleep length is **r = {stats['correlation_start_to_duration']}** (regression slope: **{stats['regression_slope']} mins per hour delayed**).
* Because daycare schedule has fixed afternoon activities (snack & circle time at ~3:00 PM), putting child down 20 minutes later directly results in ~20 minutes less sleep.
### 3. ⚖️ Weekday Routine Stability
* **Monday to Friday:** Sleep duration shows remarkable consistency across all days (<2.6 mins variance between Mon and Wed).
---
## 📅 Monthly Breakdown
| Month | Sessions | Avg Duration | Total Hours | Min Nap | Max Nap |
| :--- | :---: | :---: | :---: | :---: | :---: |
"""
for m in stats["monthly_breakdown"]:
md += f"| **{m['month']}** | {m['count']} | {m['avg_duration']} mins ({m['avg_hours']}h) | {m['total_hours']}h | {m['min_duration']}m | {m['max_duration']}m |\n"
md += """
---
## 🗓️ Weekday Breakdown
| Day of Week | Sessions | Avg Duration | Total Hours | Min Nap | Max Nap |
| :--- | :---: | :---: | :---: | :---: | :---: |
"""
for w in stats["weekday_breakdown"]:
md += f"| **{w['weekday']}** | {w['count']} | {w['avg_duration']} mins ({w['avg_hours']}h) | {w['total_hours']}h | {w['min_duration']}m | {w['max_duration']}m |\n"
md += f"""
---
## 🏆 Records & Milestones
* **Longest Recorded Nap:** **{longest['duration_minutes']} minutes ({longest['duration_hours']} hours)** on **{longest['date']}** ({longest['start_time_str']} – {longest['end_time_str']}, Logged by {longest['teacher']}).
* **Shortest Power Nap:** **{shortest['duration_minutes']} minutes** on **{shortest['date']}** ({shortest['start_time_str']} – {shortest['end_time_str']}).
---
*Report generated automatically by Brightwheel Nap & Sleep Analyzer.*
"""
with open(output_path, "w", encoding="utf-8") as f:
f.write(md)
def render_html_showcase(stats, sessions, student_name, template_path, output_path):
if not os.path.exists(template_path):
print(f"Warning: Template file {template_path} not found. Skipping HTML generation.")
return
with open(template_path, "r", encoding="utf-8") as f:
template_html = f.read()
data_payload = {
"student_name": student_name,
"generated_at": datetime.now().strftime("%B %d, %Y"),
"statistics": stats,
"sessions": sessions
}
payload_json_str = json.dumps(data_payload, ensure_ascii=False)
rendered = template_html.replace("__NAP_DATA_JSON__", payload_json_str)
with open(output_path, "w", encoding="utf-8") as f:
f.write(rendered)
def main():
parser = argparse.ArgumentParser(description="Analyze Brightwheel Nap and Sleep data.")
parser.add_argument("--input", "-i", required=True, help="Path to activities.json, activities.csv, or export folder.")
parser.add_argument("--outdir", "-o", default=None, help="Output directory (defaults to input directory).")
parser.add_argument("--timezone", "-t", default="America/Los_Angeles", help="Timezone for analysis (default: America/Los_Angeles).")
parser.add_argument("--student-name", "-n", default=None, help="Override student name.")
args = parser.parse_args()
try:
tz = zoneinfo.ZoneInfo(args.timezone)
except Exception:
print(f"Invalid timezone '{args.timezone}', falling back to UTC.")
tz = timezone.utc
# Resolve input path
in_path = os.path.abspath(args.input)
if os.path.isdir(in_path):
json_candidate = os.path.join(in_path, "activities.json")
csv_candidate = os.path.join(in_path, "activities.csv")
if os.path.exists(json_candidate):
in_file = json_candidate
elif os.path.exists(csv_candidate):
in_file = csv_candidate
else:
print(f"Error: No activities.json or activities.csv found in directory {in_path}")
sys.exit(1)
out_dir = args.outdir or in_path
else:
in_file = in_path
out_dir = args.outdir or os.path.dirname(in_path) or "."
os.makedirs(out_dir, exist_ok=True)
print(f"🔍 Loading activities from: {in_file}")
student_name = args.student_name
if in_file.endswith(".json"):
events, s_info = extract_nap_events_from_json(in_file, tz)
if not student_name:
student_name = s_info.get("first_name") or s_info.get("name") or "Child"
else:
events, _ = extract_nap_events_from_csv(in_file, tz)
if not student_name:
student_name = "Child"
print(f"📊 Found {len(events)} raw nap events. Pairing chronological sessions...")
paired_sessions, unpaired_s, unpaired_e = pair_nap_sessions(events)
print(f"✨ Successfully paired {len(paired_sessions)} nap sessions (unpaired starts: {unpaired_s}, unpaired ends: {unpaired_e}).")
if not paired_sessions:
print("No paired nap sessions found.")
sys.exit(0)
stats = compute_statistics(paired_sessions)
# Save nap_sessions.csv
csv_out = os.path.join(out_dir, "nap_sessions.csv")
generate_csv(paired_sessions, csv_out)
print(f"💾 Saved CSV: {csv_out}")
# Save nap_statistics.json
json_out = os.path.join(out_dir, "nap_statistics.json")
with open(json_out, "w", encoding="utf-8") as f:
json.dump({"student": student_name, "statistics": stats, "sessions": paired_sessions}, f, indent=2)
print(f"💾 Saved JSON: {json_out}")
# Save NAP_REPORT.md
md_out = os.path.join(out_dir, "NAP_REPORT.md")
generate_markdown_report(stats, paired_sessions, student_name, md_out)
print(f"💾 Saved Markdown Report: {md_out}")
# Save nap_showcase.html
script_dir = os.path.dirname(os.path.abspath(__file__))
tpl_path = os.path.join(script_dir, "templates", "nap_showcase_template.html")
html_out = os.path.join(out_dir, "nap_showcase.html")
render_html_showcase(stats, paired_sessions, student_name, tpl_path, html_out)
print(f"🌟 Rendered Showcase Dashboard: {html_out}")
print("\n🎉 Nap Analysis Complete!")
print(f" • Total Naps: {stats['total_sessions']}")
print(f" • Total Sleep: {stats['total_sleep_hours']} hours")
print(f" • Mean Duration: {stats['mean_duration_minutes']} mins")
print(f" • Consistency: {stats['consistency_score_pct']}%")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff