36 KiB
ParentCalendar — Product & Technical Spec
Nov 5, 2026 · @Dan
Overview
ParentCalendar turns a photo of a paper calendar into events you can trust, in under two minutes, every month. A vision model reads the page, you check and fix the results side by side with the photo, and the app exports a standard .ics file or keeps a subscribed feed up to date. Apple's iCloud Calendar is the primary target, and Google Calendar is also supported.
The problem. Every month brings several paper calendars: the school district calendar, the lunch menu, the classroom newsletter, sports and activity schedules. Each has a different layout, and typing them in by hand takes 20–40 minutes and is easy to get wrong.
Goals
- Capture to calendar in under 2 minutes for a typical one-page month grid.
- Extraction accurate enough that most months need only a few edits, and every guess is flagged.
- Works with Apple's iCloud Calendar first, and with Google Calendar, without a native app or calendar API login.
- Gets faster each month: the app remembers each source's layout, children and defaults.
- Self-hosted only, run directly with Python or as a Docker image. Your household comes first; other parents can get invited accounts in Phase 3: phone-first, nothing to install.
Non-goals for v1
- Two-way sync or editing events back from Apple or Google.
- Reading handwriting reliably (best effort, always flagged).
- Shared family accounts and permissions (planned for later).
- Native iOS and Android apps; a PWA comes first.
- Live edge detection in the browser and an offline capture queue. The phone's camera plus server-side cleanup covers capture.
- Calendars that aren't in English, until a real one turns up.
- A hosted service or a database server. This won't be sold as a service, so SQLite is the only backend.
Users
- Primary: a parent with one to four children, scanning three to six calendars a month on an iPhone.
- Secondary: a co-parent or grandparent who only subscribes to the resulting calendar.
User flow
The monthly loop has six steps, and on a return visit steps 2 and 5 are mostly pre-filled from the source's saved profile.
- Capture. Take a photo with the phone's camera or upload an image, PDF, or screenshot. The server handles crop and perspective correction. Multi-page capture is supported for two-sided sheets.
- Tag the source. Pick a saved source ("Lincoln Elem — District", "Soccer U10") or create one, and pick the child or children it applies to. The source sets the date hint, children, relevance filter ("Grade 3", "Team Blue"), default category, and default times.
- Parse. The server cleans up the image and sends it to the extraction model with the source's hints. The parsed result streams back in 10–30 seconds.
- Review and edit. A split view shows the photo beside an editable event list. Low-confidence items come first and are highlighted on the photo. Events that don't match the relevance filter appear unchecked. You can edit, delete, merge, or add events, and bulk-apply changes (for example "all 'Early Release' events end at 1:30 PM").
- Choose a calendar. Pick the calendar to export: a filtered view of events by child and, optionally, source. Set options such as alerts.
- Export. Publishing confirms the checked events. Then either download an .ics file to import once, or publish to a subscribed feed that Apple and Google refresh on their own. On iPhone, opening the .ics shows "Add All" directly.
After the first month, the target is: open app, photo, confirm source, glance at the flagged items, publish.
To change an event later, such as a game that moved, open the calendar screen rather than the original scan (see Review and edit UI).
Lunch menus follow a different path. A scanned menu doesn't become calendar events. You go through it with your kids, each child picks the days to pack, and only those days reach the calendar (see Lunch menus).
Architecture
Recommendation: a PWA front end, a small API with a background job queue, a vision LLM for extraction, and SQLite plus local disk. It runs as one process on one box, directly with Python or as a Docker image. There is no hosted version, and SQLite is the only backend.
flowchart LR
PWA["PWA client<br/>capture + review"] <--> API["API<br/>sources, export"]
API --> W["Job worker<br/>in the API process"]
W <--> M["Vision model<br/>local Ollama, or Claude"]
W -->|raw events| V["Validators<br/>dates, double read, match existing"]
V --> DB[("Database<br/>SQLite + images")]
API <-->|read / write| DB
DB -->|confirmed events| ICS["ICS + feeds<br/>RFC 5545, webcal"]
ICS --> AC["Apple Calendar<br/>subscribe or import"]
ICS --> GC["Google Calendar<br/>From URL or import"]
The worker reads a flyer or list in one model call, and a grid page (a month calendar or a menu) in one call per week row. With double read on, it reads everything a second time and flags where the two reads disagree. The validators score what comes back, and only events you confirm in review reach the ICS service and the feeds.
| Component | Responsibility | Suggested tech |
|---|---|---|
| PWA client | Photo capture and upload, review UI, lunch screen, calendar screen, settings | Svelte 5 + Vite as a single-page app with hash routes (no SvelteKit), in TypeScript, built to static/ that the API serves; <input type="file" accept="image/*,application/pdf"> for the camera and file picker. A web app manifest makes it installable; there is no service worker, since offline use is a non-goal |
| API | Uploads, sources, events, menus, export, feed endpoints; login from Phase 3 | Python with FastAPI |
| Job worker | Preprocess image, call extraction model, validate, store draft | A background task in the API process, driven by a simple job table |
| Extraction model | Image → structured events JSON | A local vision model through Ollama by default (nemotron-3.5-bolt:e4b on an 8 GB GPU), with Ollama's JSON-schema output. Claude through the Anthropic API is the optional alternative. Both sit behind one function, so the model can be swapped with a setting |
| Storage | Users, sources, scans, events, menus; original images | SQLite (WAL mode) through Python's sqlite3, with nightly backups; images on local disk |
| ICS service | Builds .ics files and serves per-calendar feeds | icalendar |
Why a vision LLM rather than OCR + rules. Paper calendars are month grids with merged cells, clip art, and items spilling across days. Plain OCR returns text without knowing which cell it belongs to, so you would need a grid detector and many per-layout rules. A vision model reads the layout and the text together and can return typed events directly. Classic OCR stays useful as a cross-check (see Parsing pipeline).
Why a PWA. One codebase covers iPhone and Android, the camera works from the browser, and there is no app store step for other parents. It can become a native app later through Capacitor if push notifications or share-sheet import become important.
Packaging. The repo can run two ways, using the same code and the same configuration:
- Front-end build. The source lives in
frontend/, andcd frontend && npm ci && npm run buildwrites the built PWA tostatic/, which is gitignored. Re-run it after any front-end change. Node is needed only for this step, not to run the app. - Plain Python. From a clone, build the front end once, then run
pip install -r requirements.txt && python main.py. Ifstatic/is missing,python main.pyexits with a message giving the build command. Prefer dependencies that ship binary wheels (Pillow,opencv-python-headless,pypdfium2) so no system packages are required. The Tesseract cross-check stays optional because it needs one. - Docker image. A multi-stage Dockerfile builds the front end in a Node stage and copies
static/into a Python image, which runs the samepip installand startspython main.py. Node isn't in the final image. The repo includes an example Compose file that mounts a data volume.
Both include python main.py backup <destination>, which runs SQLite's online backup and copies the images folder. Schema migrations are numbered SQL files applied at startup and tracked with PRAGMA user_version.
Configuration. All settings come from environment variables or a .env file:
| Variable | Purpose | Default or example |
|---|---|---|
PUBLIC_URL |
Base URL for feed links, and the domain used in new event UIDs | Required; for example https://calendar.example.com |
DATA_DIR |
Database and images; must be a local disk | ./data when run directly, /data in Docker |
OLLAMA_URL |
Ollama server for the extraction model | http://localhost:11434 |
EXTRACT_MODEL |
Ollama model name, or a claude-* model to use the Anthropic API instead |
nemotron-3.5-bolt:e4b |
EXTRACT_TEMPERATURE, EXTRACT_TOP_P, EXTRACT_TOP_K |
Ollama sampling settings. Top P and top K fall back to the model's own when not set | 0, not set, not set |
EXTRACT_NUM_CTX |
Ollama context size in tokens. Raise it if a dense page or thinking runs out of room | 16384 |
EXTRACT_KEEP_ALIVE |
How long Ollama keeps the model loaded after a call: 10m, 1h, 0 to unload, -1 forever |
10m |
EXTRACT_THINK |
1 to let an Ollama model reason before answering |
0 |
EXTRACT_THINK_LEVEL |
low, medium or high, for models with thinking levels. Only used with EXTRACT_THINK=1 |
Not set |
ANTHROPIC_API_KEY |
Only needed for a claude-* model |
Not set |
EXTRACT_IGNORE |
Comma-separated labels dropped from every source's menu items and event titles. Each source can add its own in ignore_labels |
Not set; for example BHS,Milk |
HOST, PORT |
Bind address | 127.0.0.1, 8000 |
IMAGE_RETENTION_DAYS |
Days to keep images after publish | 90 |
SHOW_SCAN_STATS |
1 adds a "Scan Stats" button to each scan's page: model, tokens used, pages, events and scan time. Every scan's time is logged either way |
0 |
Deployment on the home server
- The Docker image runs from one Compose file and binds to 127.0.0.1, and nginx proxies it on a subdomain set by
PUBLIC_URL. Split the worker out only if parsing starts slowing the API. - Keep the database and images on a local disk. SQLite's WAL mode isn't reliable on a network share.
- HTTPS is required. PWA features need a secure origin, and Google's servers fetch feeds over the public internet. Assume iCloud-synced subscriptions do too.
- Only
/feed/*is public. Through Phases 1 and 2 the rest of the app is limited to your LAN and WireGuard, with no login code. Phase 3 adds login for invited parents. - Put the feed path under the same CrowdSec bans and nginx rate limits as your other sites, and add an Uptime Kuma check for it.
- Back up nightly by scheduling
python main.py backup.
Repository. The code lives in its own git repo, which can be public. The golden set holds real school calendars that could identify your family, so it stays out of that repo, either on the server or in a separate private repo.
Parsing pipeline
Every model call returns strict JSON. Deterministic checks then run on that JSON, so the model never decides on its own that its output is correct.
- Preprocess. Fix EXIF rotation and downscale to a long edge of about 2,000 px. Render PDFs per page. Keep both the original and the processed image. The review screen shows the processed image, so
cell_bboxlines up with it. - Extract. A flyer or list page goes to the model whole, with a prompt that includes the date hint, the source's relevance filter, known patterns, and few-shot corrections from earlier months. Grid pages are read row by row (see below). The source profile sets the layout (month grid, menu, or list) and the month hint, and the first scan of a new source asks for them.
- Clean up. Pad dates to
YYYY-MM-DDand drop events whose title is empty or just "event". Drop labels from menu items and event titles: grade ranges such as "K-8" or "6-12" always, plus the words inEXTRACT_IGNOREand the source'signore_labels. A label is dropped only when it is the whole item, in any case, so "BHS Pizza" stays. - Validate. Run code-level checks:
- The date exists, and in a grid, the weekday column matches the date's real weekday. This catches most off-by-one-row mistakes.
- Times parse, and any end time is after the start time. A missing end time is allowed.
- The dates fall inside the range on the page header and agree with any hint.
- Duplicates inside the scan are found.
- With double read on, any event or menu day where the two reads disagree, or that only one read found.
- Match existing events. Compare each event with the source's existing events on overlapping dates and a fuzzy title. Winter Break can appear on both the December and January pages, and a revised calendar can be scanned mid-month. A match keeps its
uidand shows in review as unchanged or updated, and an uncertain match asks you. - Score. Combine the model's own confidence with any failed checks into a per-event score: high, review, or likely wrong. Optional cross-check: run Tesseract and flag events whose title text is not found on the page (a hallucination guard).
- Store a draft. Save events as a draft scan, never straight to a calendar.
Grid pages, one week row at a time. A small local model can read the text on a month grid but places it in the wrong cell, so it never computes dates on its own:
- Find the rows. Straighten the photo (the angle whose row profile is sharpest), then find horizontal lines that cross the middle of the page. Walk down from the first row, taking the next line about one row-height below, so a long arrow or a banner edge doesn't split a row. Dense bands such as a menu's picture banner are skipped, and text spilling below the last row stays with that row.
- Read each row. Send the weekday headings plus one row. The heading strip is cropped to the line of weekday names, so a note printed above one column, like "2hr Early Out" over Friday, doesn't appear in every row. The model reports what is printed: the weekday and day number of each column, and the column each item starts and ends in.
- Date the rows in code. The printed day numbers vote on each row's dates, which handles rows that cross into the next month or year. The rows then vote on where the page starts, because they are consecutive weeks. A row whose numbers were unreadable or misread is corrected and flagged. Dates printed in an item's own text, like "10/23-10/29", win over the drawing, but only when the text really contains a date.
- Keep each list in its place. On a menu page, only closures become events; anything else is misread food. A calendar page keeps no menu items.
Double read. An optional setting per source reads every page or row a second time, with a slightly wider crop shrunk to 85%. The first read's values are kept. Disagreements only add flags with a reason, such as "second read differs: end_date", and items only the second read found are added, flagged. It roughly doubles the time per page.
Example of the extraction schema the model must fill:
{
"layout": "month_grid",
"date_range": { "start": "2026-12-01", "end": "2026-12-31" },
"events": [
{
"date": "2026-12-18",
"end_date": null,
"all_day": false,
"start_time": "13:30", "end_time": null,
"title": "Early Release",
"notes": "Dismissal 1:30 PM",
"kind": "no_school | early_release | event | game | practice | deadline",
"applies": true,
"page": 1,
"cell_bbox": [x, y, w, h],
"source_text": "EARLY RELEASE 1:30",
"confidence": 0.82
}
],
"menu_items": [
{
"date": "2026-12-18",
"items": ["Pizza", "Apple slices"],
"no_lunch": false,
"page": 1,
"cell_bbox": [x, y, w, h],
"confidence": 0.9
}
]
}
This is the whole-page schema. For a grid row, the model returns the row's columns (weekday and printed day number) and each item's start and end column instead of dates, and code fills in the fields above.
source_text, page and cell_bbox drive the review screen: tapping an event highlights where it came from on the photo. On grid pages, cell_bbox is the whole week row. cell_bbox uses values from 0 to 1, relative to the processed page image. date_range covers whatever the page shows, from one month to a full school year. applies is false when an event doesn't match the source's relevance filter, and those events are still returned. Multi-day items such as "Winter Break Dec 21–Jan 2" become one event with end_date. Food goes in menu_items, never events, and a page that mixes both, such as a newsletter with a menu, fills both lists.
Data model
The key decision is that every event has a permanent uid, created once and reused in every export, so refreshing a feed updates events instead of duplicating them. The second is that events are tagged with children, and a calendar is a filtered view of those tags, so one edit reaches every feed.
| Table | Key fields | Notes |
|---|---|---|
user |
id, email, timezone, created_at | Timezone defaults from the browser |
child |
id, user_id, display_name, color | A nickname or initials is enough; no birthdate |
source |
id, user_id, name, layout_type, double_read, ignore_labels[], default_kind, default_times (by kind), keep_kinds[], applies_filter, prompt_notes, default_calendar_id | The per-source profile reused each month. ignore_labels lists words this source prints that are never food or events, such as "BHS"; they are added to the server-wide EXTRACT_IGNORE list. Phase 1 uses name, children, layout_type, double_read, ignore_labels, applies_filter and default_calendar_id; the rest fill in during Phase 2 |
scan |
id, source_id, hint_month, date_start, date_end, image_keys[], image_hash, status (queued, parsed, failed, abandoned, reviewed, published), error, model, cost_tokens, seconds, created_at, published_at | One upload of one or more pages. hint_month is the month picked at upload; date_start and date_end are what the pages turned out to cover, possibly many months |
event |
id, uid, scan_id, source_id, date, end_date, all_day, start_time, end_time, title, notes, location, kind, applies, include, page, cell_bbox, source_text, confidence, flags[], status (draft, confirmed, deleted), match (new, updated, unchanged), replaces_id, sequence, updated_at | sequence increments on every change after publishing. include is the review checkbox. A draft with replaces_id updates that confirmed event at publish, keeping its UID. updated_at has millisecond precision because "changed since the last download" compares it |
calendar |
id, user_id, name, color, source_ids[], feed_token, alarm_minutes, last_exported_at | A view by child and, optionally, by source. An empty source_ids means all sources. feed_token is the secret in the feed URL |
source_child, event_child, calendar_child |
Parent id, child_id | One event, source or calendar can link to several children. A family calendar links all of them |
correction |
id, event_id, batch, field, before, after, undone, created_at | One row per changed field. A batch is one edit or one bulk action, and undo reverts a whole batch. Few-shot examples in Phase 2 |
menu_item |
id, scan_id, source_id, date, items[], no_lunch, page, cell_bbox, confidence | One row per source and date. Never exported |
job |
id, scan_id, status, attempts, run_after | The background work queue |
More tables come later. Phase 2 adds rule (saved bulk rules) and event_source (links a merged event to each of its sources). Phase 3 adds household (shared access for a co-parent) and invite and credential for login. Recurring patterns would add rrule and exdates fields to event.
Review and edit UI
The review screen is where trust is earned, so it shows the photo and the results together and puts the uncertain items first.
- Layout. On a phone, the photo sits above the event list, with a toggle to a month-grid preview. On desktop, they sit side by side. Tapping an event zooms the photo to its
cell_bbox. Tapping a cell on the photo lists that day's events. - Ordering. Sort events into five groups, by month within each group when a page spans several: "Needs a look" (low confidence or a failed check, each with the reason, such as "weekday mismatch"), "Updated" (matched an existing event, with the differences shown), "New", "Unchanged" (collapsed), and "Not for this child" (doesn't match the relevance filter; unchecked and collapsed, with a count, and one tap restores an event).
- Editing. Edit inline: title, date, all-day or timed, start and end, kind, children, notes. Swipe to delete. Add an event by long-pressing a date in the grid preview.
- Bulk actions. Select several events by kind or text and apply one change, such as setting times, changing kind, or deleting all
practiceitems. From Phase 2, offer to save a bulk rule to the source ("always 1:30 PM for Early Release"). - Preview. A month grid shows the final result in the target calendar's color before export.
- Keyboard and accessibility. Full keyboard editing on desktop, 44 px touch targets, and confidence shown by an icon and label, not only by color.
- Light and dark theme. The app follows the device's light or dark setting. Settings can force light or dark; that choice is saved on each device.
- Undo. Every edit can be undone until publish. After publish, edits create a new
sequenceinstead of a new event.
Each edit is saved as a correction, which powers undo and later becomes few-shot examples so the same source parses better next month.
Calendar screen. After publishing, browse events by child and calendar, and edit or delete them with the same form used in review. Feeds pick up the change on their next refresh, and the next .ics download includes it. The source photo stays available until the retention period ends.
Lunch menus
A menu isn't a list of events. It's something you go through with your kids, and only their choices reach the calendar.
- Scan. Capture the menu like any other page, using a source linked to the children who eat from it. Review corrects the items by date. Kids at different schools use separate menu sources.
- Pick. The lunch screen shows the month with each day's menu, one child at a time. Each child taps the days to pack.
- Sync. Each picked day creates a confirmed event titled "Pack lunch — ", with kind
pack_lunch, tagged with that child. It's an all-day event on the school day, and it reaches every calendar that includes that child, by feed or .ics, like any other event.
Rules
- A pick is simply a
pack_lunchevent, one per child per date, so there's no separate picks table. The app creates these events and the model never returns them. - Un-tapping a day marks its event deleted. Tapping it again restores the same
uidwith a newsequence. - A day is greyed out and can't be picked when a confirmed
no_schoolevent tagged with that child covers it, or when the menu marks itno_lunch. If the greyed day is wrong, fix theno_schoolevent on the calendar screen. - If a
no_schoolevent arrives after a day was picked, the pick stays and is flagged on the lunch screen instead of being removed automatically. - Scanning a revised menu replaces that source's items for those dates and keeps existing picks.
- Menu items never appear in a feed or .ics file.
ICS export and import
Ship two export modes: a one-time .ics download in v1, and subscribed feeds in v2. For a monthly habit, feeds are the better default because you subscribe once and every later month and correction appears on its own, with no duplicates.
| One-time .ics download | Subscribed feed (webcal://) |
|
|---|---|---|
| Setup | None | Subscribe once per calendar |
| Corrections after export | Re-import may duplicate, depending on the client | Automatic on the next refresh |
| Apple Calendar | iPhone: open the file, tap "Add All". Mac: double-click or File → Import | iPhone: Settings → Calendar → Accounts → Add Subscribed Calendar. Mac: File → New Calendar Subscription (refresh interval can be set) |
| Google Calendar | Desktop web only: Settings → Import & export | Other calendars → From URL. Google sets the refresh rate and it can lag by hours |
| Best for | First release, people who want events copied in | Ongoing monthly use, co-parents and grandparents |
Apple first. iCloud Calendar is the main use case, so Apple behavior gets tested first and decides any tie. Three things need a real test on your devices in Phase 0:
- Whether a subscription reaches all your devices through iCloud, and from which device it has to be added.
- Whether re-importing an .ics with a matching
UIDupdates the event or duplicates it, and whetherSTATUS:CANCELLEDremoves an imported event. - What happens when one event, with one
UID, is in both a child calendar and the family calendar on the same account.
What a download contains. By default, an .ics download holds events new or changed since that calendar's last_exported_at, including deletions as STATUS:CANCELLED. A "download everything" option covers a fresh start.
ICS generation rules (RFC 5545), handled by the library but tested explicitly:
VERSION:2.0, a stablePRODID, andX-WR-CALNAMEandX-WR-TIMEZONEfor a readable calendar name.- All-day events use
DTSTART;VALUE=DATEwith an exclusiveDTEND(the day after). Multi-day breaks run through the day after the last day. - Timed events use
TZIDwith a matchingVTIMEZONE, or UTC. Never use floating times. - A timed event with no end time and no source default omits
DTEND, which makes it zero-length.DTENDcan't equalDTSTART, because RFC 5545 requires it to be later. - A stable
UIDper event, in the form<random id>@<domain from PUBLIC_URL>, plusDTSTAMP,SEQUENCE, andLAST-MODIFIEDon each change. The fullUIDis stored when the event is created, so changingPUBLIC_URLlater never changes existing UIDs. It does change feed URLs, which means subscribing again. - Feeds carry the past 12 months plus all future events. Deleted events stay in the feed as
STATUS:CANCELLEDfor 30 days, then are dropped. CATEGORIESfromkind, an optionalVALARMfrom the calendar's alert default, CRLF line endings, 75-octet line folding, and escaped commas, semicolons, and newlines. Don't rely onVALARMin subscribed feeds: Google tends to ignore it and Apple can strip it, so test it on real devices.
Feed endpoint. GET /feed/<feed_token>.ics with no login. The token is long, random, and can be rotated. The response sets ETag and Cache-Control so clients that poll often stay cheap.
Later option: direct Google Calendar API and CalDAV writes for instant updates. These need OAuth and Google app verification, so they wait until feeds prove too slow.
Built for monthly use
The second month should take half the effort of the first. These features are how that happens, roughly in priority order.
Phase 2
- Source profiles. Each source remembers its layout type, children, target calendar, default times by kind, which kinds to keep (for example, skip
deadlineitems), and free-text prompt notes ("'A Day' and 'B Day' are rotation labels, not events"). - Learning from corrections. The last 5–10 corrections for a source go into the prompt as examples. Saved bulk rules ("Early Release → 13:30") run after extraction, before review.
- Month-over-month diff. Compare against the source's previous month: flag recurring items that disappeared ("Practice Tuesdays missing in December?") and new kinds of entries.
- Smart dedupe across sources. "No School — Thanksgiving" from the district calendar and the class newsletter merges into one event, linked to both sources.
- Per-child calendars and colors. One feed per child plus an optional combined family feed, so a co-parent can subscribe to just one child.
- Mid-month corrections. Re-scan a revised calendar and see only the differences, which then update existing events through their
UID.
After Phase 3
- Recurring patterns. Detect weekly repeats ("every Tue and Thu practice 5:30") and offer an
RRULEwith exceptions instead of 9 separate events. - More ways to capture. Batch capture of several sources at once, plus the share sheet (PWA Web Share Target) to send a PDF from email directly. As far as we know, iOS Safari doesn't support Web Share Target, so this may be Android-only. Later: a forwarding email address that parses attachments from the school.
- Monthly nudge. An optional reminder near the end of each month: "Your 4 sources usually publish around now." This needs email or web push.
Privacy, security, and cost
These calendars reveal where children will be and when, so the defaults keep as little data as possible and keep it for as short a time as possible.
Privacy
- Store only a nickname for each child. No birthdates, schools, or photos of children are required.
- Delete images 90 days after publish by default (configurable), and delete abandoned scans' images on the same timer. Extracted events stay until the user deletes them, and feeds carry only the past 12 months plus future events.
- By default the model runs on your own server through Ollama, so scans never leave the house. If a
claude-*model is configured, use an API whose terms rule out training on your inputs, and tell invited parents that their scans go to that provider. - Account deletion removes everything and revokes all feeds right away.
Security
- Feed URLs are bearer secrets: at least 128 bits of randomness, one-tap rotation, rate limits, and no event details in server logs.
- Optional location stripping: leave
LOCATIONoff events in shared feeds. - LAN and WireGuard protect the app through Phase 2. Phase 3 adds login through passkeys or email magic links. Image uploads are always size-limited, type-checked, and re-encoded on the server.
- Treat model output as untrusted: validate against the schema and escape everything before rendering or writing ICS. Text printed on a flyer must not be able to change the prompt's instructions.
Cost and limits
- With the local model there's no per-page charge; the limit is GPU time. On an RTX 2080 (8 GB), a page takes 10–35 seconds, or 20–65 seconds with double read. Store tokens on
scan.cost_tokenseither way. - Cache by image hash, so an accidental re-upload costs nothing.
- If a
claude-*model is configured, your API key pays for every scan, so once invited parents join in Phase 3, set a monthly page limit per user.
Roadmap
Build in four phases, each ending with a gate, and prove extraction quality on real calendars before building any UI.
flowchart LR
P0["<b>Phase 0 - Prove extraction</b><br/>Start with 5-10 calendars<br/>Prompt + JSON schema<br/>Eval script, baseline<br/>Measure tokens per page<br/>Test Apple import + iCloud"]
P1["<b>Phase 1 - MVP for you</b><br/>Upload + parse<br/>Child + calendar pickers<br/>Review, highlights, bulk edits<br/>Match existing events<br/>Calendar + lunch screens<br/>.ics download<br/>Plain Python + Docker image"]
P2["<b>Phase 2 - Monthly loop</b><br/>Source profiles<br/>Webcal feeds<br/>Learn from edits<br/>Diff + dedupe"]
P3["<b>Phase 3 - Invite parents</b><br/>Passkeys + invites<br/>Per-user page limits<br/>Co-parent sharing<br/>Backups + monitoring"]
P0 -->|"Gate: accuracy targets met"| P1
P1 -->|"Gate: used for 2 real months"| P2
P2 -->|"Gate: repeat month under 2 min"| P3
Each arrow is the gate to the next phase. Phase 0 is only a script and a golden set, but it decides whether the rest is worth building. Phase 3 starts only when someone actually wants an account.
Phase 1 started on Nov 5, 2026, with the Phase 0 gate nearly met (see Phase 0 status below). Recall passes, and the two misses amount to about one event each on a small golden set, so the gate gets re-checked as the set grows rather than holding up Phase 1.
Phase 0 gate. Scored on clean printed pages:
| Measure | Target | What it protects |
|---|---|---|
| No-edit rate | 85% or more | The under-2-minute goal |
| Events found (recall) | 95% or more | Missed events, which review can't flag |
| Errors that get flagged | 80% or more | Trust in events that aren't flagged |
These are starting points to adjust after the first baseline. With only 5–10 calendars, one bad page can swing them a lot, so treat them as a sanity check rather than a precise measurement.
Phase 0 status (Nov 5, 2026). The golden set has 5 pages: an October month grid (a screenshot from the school's app), two weekly flyers, and two lunch menus, one of them a photo and one spanning September and October. The scripts are phase0/extract.py, phase0/grid.py and phase0/score.py. All runs used nemotron-3.5-bolt:e4b on an RTX 2080, at temperature 0 with reasoning off. Reasoning on made it worse.
| Approach | No-edit | Recall | Errors flagged | Menu days right |
|---|---|---|---|---|
| Whole page | 57% | 76% | 83% | 36/45 |
| Week rows | 73% | 97% | 0% | 41/45 |
| Week rows, double read, tight headings | 83% | 97% | 75% | 41/45 |
The flyers score 10 out of 11. The calendar went from 7 to 15 of 18 events right. The gate isn't met yet: no-edit is 2 points short and flagged errors 5 points short, which is about one event each on this small set. Qwen 3.5 9B was also tried on whole pages. It found more calendar events than Nemotron did on whole pages, but put many on the wrong dates, flagged only 56% of its errors, and failed on the photo menu by running out of context. The golden set grows each month, and the gate gets re-checked as it does.
Testing strategy
Extraction quality is the product, so it gets an evaluation suite like any other critical component.
- Golden set. Start with 5–10 real calendars covering the main layouts, and add new ones each month until there are 20–30 of every layout type (grid, year grid, list, menu, sports, bad photos, PDFs), each with a hand-checked expected JSON. It stays outside the public repo.
- Eval script. Matches extracted events to expected ones by date and fuzzy title, then reports recall, precision, per-field accuracy (date, time, all-day, kind, applies), and the share of wrong events that were flagged. The no-edit rate is perfect events divided by expected events plus invented ones: 34 perfect out of 40 expected, plus 1 invented, is 34/41, or about 83%. Clean printed pages, bad photos, and menus are scored separately, with menus scored by date and items. Run it on every prompt or model change and fail on a regression. CI needs a secret to reach the private golden set, or the eval runs on the home server.
- Validators. Unit tests for the weekday check, multi-day ranges, multi-month pages, event matching, lunch-day greying and picks, timezone and DST edges, and month boundaries.
- ICS. Golden-file tests for generated output, an RFC 5545 validator in CI, and a manual import checklist per release: iPhone, Mac, sync across devices through iCloud, then Google web and Google on Android through a feed. The checklist includes how zero-length events display and whether alerts survive.
- UI. Playwright end-to-end tests for capture → review → export and menu → pick → export using fixture scans.
- Packaging. CI builds the front end, runs
pip install -r requirements.txtin a clean virtual environment, and builds the Docker image, then starts both and checks that each serves the app.
Open questions
- Hosting: decided. Self-hosted only, run directly with Python or as a Docker image, with SQLite as the only backend.
- Primary calendar: decided. Apple's iCloud Calendar comes first.
- Server language: decided. Python with FastAPI; the PWA stays TypeScript.
- Lunch menus: decided. They're kept separate, and only days a child picks become events.
- Phase 0 accuracy gate: decided. See the targets under Roadmap.
- Name and domain: decided. ParentCalendar, with the domain set through
PUBLIC_URL. - Do Apple's and Google's .ics imports update or duplicate events with a matching
UID? Test in Phase 0, Apple first. The answer decides whether one-time import needs a warning. - Extraction model: decided. A local model through Ollama, with Claude as an option.
- Front end: decided. Svelte 5 + Vite, as a plain single-page app.
- The kids circle or cross out days on the paper menu. Which mark means "pack lunch", and should the lunch screen pre-fill picks from a photo of a marked menu?
- Which menu applies to each child, K-8 or BHS? On some days BHS gets the other menu, which the source's relevance filter has to express.