CI Verification / Lint & Extension Integrity Check (push) Successful in 2m58s
533 lines
22 KiB
Python
533 lines
22 KiB
Python
#!/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()
|