My daughter started high school this year. Year 7. New uniform, new building, new everything — and a school calendar so packed with activities, fixtures, music lessons and extracurriculars that keeping track of it felt like a part-time job. In junior school it was easy, the same timetable every week, and printed in a homework diary - senior school becomes more real world.
I'm going to tell you how that problem turned into a fully automated family notification system running silently on a Mac mini in my study — one that scrapes two school portals, maintains a live Excel workbook, prints a formatted weekly timetable, and sends targeted iMessages to my wife, my daughter, and me on a schedule that took the better part of a day to build.
It's part AI story, part automation nerd journey, part confession from a parent who absolutely over-engineered a school calendar. But importantly also starts drawing the distinction between what you need AI for vs automation. Are we sometimes burning tokens for simple automation tasks?
It started with a Claude skill and a lot of copying and pasting
The school uses a platform called SOCS — a parent portal with a timetable view showing her week: lessons, activities, fixtures, the lot. Every week I'd have to log in, navigate to her profile, scroll through the timetable, copy the table, paste it into Claude, and say "update activities."
That "update activities" phrase triggered a Claude skill I'd built: a set of instructions that took the raw pasted data, filtered it down to the relevant Year 7 entries, asked me which activities to include, and wrote everything into a formatted and printable Excel workbook — a month-view calendar tab and a full chronological list tab - even colour coded. It was genuinely useful. It saved real time. It was also entirely manual — despite the involvement of AI.
Key Takeaway: The moment you build a skill that makes a manual process faster, you immediately start thinking about how to remove the manual part entirely. This is the slippery slope they don't warn you about.
I was logging into SOCS by hand, copying rows of data, and pasting them into Claude once a week. It took five minutes. But it was five minutes I'd have to remember to do — and five minutes that required me to be sitting at a desk with the portal open. With a Year 7 student, there are a lot of things to remember. It also relied on me being at home to perform the manual scrape.
The automation question
The natural question was: can the login and scraping step itself be automated? I looked at Claude for Chrome briefly — the idea of a browsing agent that could handle the login and extraction appealed. But it has no scheduling capability. You can't tell it to run at 6:30am every morning without someone pressing a button.
So I thought about what I had available. I have a Mac mini in my study — GROMY-SERVER, always on, always connected. macOS has launchd for scheduling. Python has Playwright for browser automation. And since I don't know how to write Python scripts, I had Claude to help me write all of it.
The architecture came together quickly: a Python/Playwright script running on the Mac mini on a schedule, logging into SOCS automatically, scraping the data, comparing it against the last known state, updating the Excel workbook if anything had changed, and sending iMessages to the family if it had. All without any human involvement after the initial setup.
Setting up GROMY-SERVER
First, I needed to turn the Mac mini into a proper development server. Over SSH with Claude walking me through it step by step, we installed Xcode Command Line Tools (the GUI popup trick doesn't work headlessly — we had to use softwareupdate from the terminal), then Homebrew, then Python 3.14, then a virtual environment in ~/school-calendar/, then Playwright with a Chromium browser, then openpyxl, reportlab, and the rest.
It sounds straightforward written down. It wasn't. The PATH was wrong after every install. Python 3.9 kept appearing instead of 3.14. pip refused to install into the system environment. Every step had a small gotcha. Claude diagnosed and fixed each one in sequence. By the end, GROMY-SERVER was fully provisioned and the venv was clean.
The SOCS login problem
SOCS uses SSO. When you land on the portal, you choose from four options — Staff, Pupil, Parent, External — and get redirected to a separate identity provider to enter your credentials before being bounced back. Playwright can handle redirect chains, but the initial approach of navigating to the main URL and clicking "Parent" was causing redirect loops in headless mode.
The fix was simpler than expected: the parent login has its own direct URL — just append /parent to the login path. By navigating straight there, the role selection step disappears entirely and the SSO flow proceeds normally. One less thing to click, one less thing to break.
After login, the script navigates to her profile — her card appears under "Children" — and then works through four tabs: Timetable, Activities, Fixtures, and Music.
Scraping the timetable — four weeks at a time
The Timetable tab shows the current week in List view: day headers, time ranges, lesson names, and rooms. The script reads four weeks by clicking the forward arrow after each one. Getting that arrow click right took several iterations — the page has a notification permission dialog that kept intercepting the click until we blocked notifications at the browser context level.
Parsing the text was interesting. The raw page content comes back with time ranges that have trailing tabs — "8:50am - 9:50am\t\t" — which broke the regex until we made the end anchor whitespace-tolerant. Items named "Pe" (two characters) were being filtered out by a noise check that discarded anything under three characters. We had to explicitly whitelist short valid subjects.
One entry that caused real trouble: "Swimming | Girls-U12A vs Sir William Perkins's School" — a swimming fixture that appeared in the timetable as a single pipe-delimited string combining the sport and the fixture name. The parser had to detect the pipe-plus-"vs" pattern and split it, keeping "Swimming" as the item name while storing the fixture detail in the location field.
And then on the first Friday morning the script ran automatically, the timetable came back with zero entries. Not a scrape failure — it had logged in, navigated correctly, and read the page. The problem was that while SOCS renders its times the same, the scraper saw things differently depending on the day: during the week it outputs "8:50am - 9:50am" with am/pm suffixes, but on a Friday it switches to 24-hour format — "8:50 - 9:50" — with no suffix at all. The parser's regex required am/pm, so it matched nothing. A one-line regex fix, but it only revealed itself in production on a Friday morning. Which is exactly the kind of thing that makes you grateful you built the failure protection before it happened.
Note: my experience here was that web scraping is never clean. The data is always slightly wrong in a way that only matters to you specifically. Budget time for this — it's not a bug, it's just the world.
Fixtures — clicking the purple "i" button
The Fixtures tab is where things get interesting. Each fixture card shows the basics — opponent, sport, meet/start/return times, home or away. But there's a purple pill button with an "i" on every card that opens a modal with the full detail: team name, sport, staff member running it, venue, home/away confirmation, and the full team sheet with every player's name.
We needed that team sheet. The whole point of the new fixture alert was to send a proper team selection notification — the kind that tells you your daughter made the squad before the school email arrives.
The "i" button detection took work. A notification permission dialog was blocking the page and returning only ten blank buttons — none of them the fixture info button. The fix was to deny all notifications at the browser context level before navigation, and then find the button using SVG-presence detection rather than text matching. Once the modal opened, the team sheet had a "Show more" link to expand the full player list. The script clicks that too.
The second portal — Firefly and the LAMDA timetable
She also does LAMDA — speech and drama. The school publishes the LAMDA lesson timetable on a separate platform called Firefly. It's a completely different system with its own login, its own navigation, and its own quirks.
The timetable is displayed as an embedded Word document — a .docx rendered inline in the browser with a document viewer. The group lists (which student is in which group) appear below the embedded document in the main page body. The script navigates directly to the Lamda teacher's page, finds the .docx download link by its visible text, downloads the file, and parses it with python-docx.
The Word document has one table with dates in the column headers and time slots in the rows. The script reads the group list from the page body text to find which group she is in — this updates automatically if her group changes at any point in the year — then cross-references against the table to find her slot. Currently: Thursday, 09:20, LAMDA Room 2.
Note: The Firefly document is re-uploaded every week with a different filename. The script finds it by the visible link text containing ".docx" rather than by URL — which means it works regardless of what they call the file each week.
The Excel workbook
Everything scraped from both portals flows into a single Excel workbook synced to OneDrive: Daughter_Timetable.xlsx. It has two sheets.
All Events is a chronological flat list — Date, Day, Time, Item, Location, Category — covering the rolling four-week window. Every run rewrites this sheet completely from the current scraped data. No duplication, no stale entries. If the school removes a lesson, it disappears from the sheet on the next run.
Fixtures is a richer sheet with the full detail from the SOCS modal: sport, team, home/away, venue, meet time, start time, return time, staff member, and the full team sheet as a comma-separated list in a single cell. This gives us a proper fixture record in one place.
The iMessage system
This is the part that made it feel like a real product rather than a script.
The Mac mini runs macOS, which means AppleScript can control Messages.app. The script generates messages and sends them via osascript. The early versions had AppleScript syntax errors caused by apostrophes in names like "St Teresa's Effingham" — fixed by writing the message text to a temporary file and reading it back in AppleScript rather than embedding it directly in the script string.
There are three recipients — Dad, Mom and daughter — and the messages are targeted based on content and relevance.
The morning run (6:30am, Monday to Friday) sends schedule change alerts to all three if anything has changed within the next 14 days. It sends today's kit reminder to my wife and daughter if there's a sport activity — PE with Swimming Pool in the location triggers a swimming kit reminder, Locker 27 gets its own reminder, Hockey and Netball theirs. And it sends a 🎹 Piano today or 🎭 LAMDA today reminder to both my wife and daughter if either is scheduled.
The afternoon run (4pm, Sunday to Thursday) sends tomorrow's full agenda to only my daughter — every lesson in time order. It sends tomorrow's kit reminder to both my wife and daughter. If there's a fixture tomorrow, it sends the full fixture detail to both of them: sport, opponent, meet time, start time, return time, venue, staff member. And the Piano and LAMDA reminders for tomorrow.
The Sunday run (9am) always sends a screenshot of the coming week's schedule to both of them, a list of fixtures for the week, and a heartbeat confirmation to me — just a message saying the automation is alive, when it last ran, and how many events are in the database. If the script ever fails, I get a failure alert immediately.
The Real Point: The change alert only fires for events within the next 14 days. Changes to weeks three and four are saved silently to the database. You don't need to know about a hockey match in six weeks. You need to know about one tomorrow.
New team selection
This was the feature that made my wife's eyes light up. When the script detects a fixture for the first time — not just a change to an existing one, but a brand new fixture appearing in the data — it sends a team selection alert to all three of us:
- New Team Selection!
- 🗓️ Monday, 21 September
- 🏑 Hockey
- 🆚 St Teresa's Effingham
- 👭 Girls-U12A
- 📋 Name 1
- 📋 Name 2
- 📋 ... and seven more
It only fires once per fixture. Once it's in the state file, subsequent runs don't re-trigger it. And it fires for any week — not just the current one — because finding out your daughter made a team is always worth knowing immediately.
What actually took the time
None of this was a straight line. The things that took the most time were not the clever bits.
The notification permission dialog that blocked the fixture buttons — invisible in normal browser use, fatal in headless Playwright. The "Pe" subject being two characters long and failing a noise filter. Time ranges with trailing tab characters that broke a regex anchored to end-of-line. The same time ranges rendering in am/pm format on weekdays and 24-hour format on Fridays — same portal, same page, different output depending on the day. Deduplication across multiple scrape sources producing four copies of the same entry. The Excel sheet accumulating duplicates on every run until we changed the write strategy to replace all rows rather than appending. And the first Friday morning run that returned zero timetable entries and silently wiped the state file before we added the failure protection.
Each one was a five-minute fix once diagnosed. Although AI was helping me to it, diagnosis was the work.
The physical layer — a printed timetable, automatically
Once the digital side was running cleanly, I noticed a gap. The iMessages were landing at the right times. The Excel workbook was staying current. But my daughter is still a kid. She doesn't live in a spreadsheet. What she actually needed was something she could stick on the fridge and stuff in her bag — a single sheet showing the whole week at a glance.
So I added a third piece to the Sunday sequence. At 10am, a separate launchd job fires a Python script that reads the All Events sheet, extracts next week's Monday-to-Friday data, and builds a fully formatted Excel file from scratch — colour-coded by subject, landscape A4, fitted to print on a single page. Each subject has its own accent colour baked into a lookup table: Maths gets deep red, English navy, Geography burnt orange, the sports and clubs a uniform slate. The time slots run down the left, the days across the top, a colour legend at the bottom. It looks deliberate. It doesn't look like a script built it.
That file gets written to the same OneDrive folder as everything else — so it syncs to every device immediately — and then at 10:30am a second launchd job opens it in Excel via AppleScript and sends two copies to the AirPrint printer. No dialogs. No human involvement. Two warm sheets in the output tray by the time anyone comes downstairs.
Getting the print job to work from launchd took longer than it should have. The lp command works fine from a terminal session but throws an "Operation not permitted" error when called from a launchd agent — macOS blocks print access outside of a user GUI session. The fix was osascript: tell Excel to open the file, tell the active sheet to print, close without saving. Two print calls, back to back, for two copies. The copies parameter in the AppleScript print command silently broke it, so we called it twice instead. Dumbing it down to an inelegant solution, sometimes is the one that actually works.
Note: launchd runs in a restricted environment that doesn't inherit your normal shell PATH or session permissions. Things that work perfectly in a terminal window can fail completely when called from a scheduled job. When something breaks only in launchd, suspect the environment before suspecting the code.
The final touch: at 1pm on Sunday, an iMessage goes to my daughter's Apple ID — "🖨️ Collect your timetable off the printer." Not 10:30 when it prints, because she is at Netball Club at that time. One o'clock, she would be back home and like any digital kids these days likely on her phone to catch up on what banter she missed on the group chats whilst at netball.
The Sunday sequence now runs like this: 9am scrape and heartbeat, 10am build and format the weekly timetable, 10:30am print two copies, 1pm nudge her to go and get them. Nobody has to think about it. Nobody has to remember it. It just happens.
The failure protection layer
There's a failure mode that took a week of real-world running to discover. On the first Friday morning, SOCS timed out mid-scrape. The script got zero timetable entries, compared them against 151 known events, decided 150 things had been removed, updated the Excel and the state file accordingly, and dutifully sent the family an iMessage telling them everything was gone. It wasn't. SOCS had just had a bad morning.
The fix was a sanity check: if the new event count is less than 50% of what was in the state file last time, treat it as a failed scrape. Don't update the state. Don't touch the Excel. Send me a failure alert and exit cleanly. The rolling four-week window in the database exists precisely for moments like this — the data stays intact until the next successful scrape replaces it. A bad run leaves no trace.
Where I am now
The script is running. It has been since we deployed it. The launchd jobs fire on schedule. The Excel file updates in OneDrive. The iMessages arrive. The timetable prints.
This morning — Friday — my family got a swimming kit reminder at 6:30am and a LAMDA reminder on Thursday. My daughter had PE in the Swimming Pool at 8:50 and LAMDA at 9:20. Nobody had to remember to check anything or to log into SOCS. Crucially nobody had to manually copy and paste a table into Claude. And on Sunday morning, without anyone pressing anything, next week's colour-coded timetable will appear on the printer in two copies — one for her school bag, one for the fridge.
That's what started as a skill. It's now a small automation that quietly takes several things off the mental load of navigating a new school year. I'll take it.
The script is about 1,600 lines of Python, a Node launcher, three shell scripts, and five launchd jobs. It took one long day to build the core with Claude as the pair programmer, another few hours of debugging with real terminal output, and an afternoon on the physical layer. The total cost was a weekend, a lot of SSH sessions, and one very patient AI. The payoff is every morning — and every Sunday — it works without me.
The other key thing, all data is resident locally in my home (behind my own security layers) and nothing is impacting any subscriptions each time it runs - so costs are contained.