#!/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()