Files
parentcalendar/docs/SPEC.md
T
2026-11-06 11:50:06 -06:00

36 KiB
Raw Blame History

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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").
  5. Choose a calendar. Pick the calendar to export: a filtered view of events by child and, optionally, source. Set options such as alerts.
  6. 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/, and cd frontend && npm ci && npm run build writes the built PWA to static/, 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. If static/ is missing, python main.py exits 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 same pip install and starts python 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.

  1. 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_bbox lines up with it.
  2. 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.
  3. Clean up. Pad dates to YYYY-MM-DD and 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 in EXTRACT_IGNORE and the source's ignore_labels. A label is dropped only when it is the whole item, in any case, so "BHS Pizza" stays.
  4. 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.
  5. 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 uid and shows in review as unchanged or updated, and an uncertain match asks you.
  6. 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).
  7. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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 practice items. 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 sequence instead 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.

  1. 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.
  2. Pick. The lunch screen shows the month with each day's menu, one child at a time. Each child taps the days to pack.
  3. 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_lunch event, 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 uid with a new sequence.
  • A day is greyed out and can't be picked when a confirmed no_school event tagged with that child covers it, or when the menu marks it no_lunch. If the greyed day is wrong, fix the no_school event on the calendar screen.
  • If a no_school event 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 UID updates the event or duplicates it, and whether STATUS:CANCELLED removes 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 stable PRODID, and X-WR-CALNAME and X-WR-TIMEZONE for a readable calendar name.
  • All-day events use DTSTART;VALUE=DATE with an exclusive DTEND (the day after). Multi-day breaks run through the day after the last day.
  • Timed events use TZID with a matching VTIMEZONE, or UTC. Never use floating times.
  • A timed event with no end time and no source default omits DTEND, which makes it zero-length. DTEND can't equal DTSTART, because RFC 5545 requires it to be later.
  • A stable UID per event, in the form <random id>@<domain from PUBLIC_URL>, plus DTSTAMP, SEQUENCE, and LAST-MODIFIED on each change. The full UID is stored when the event is created, so changing PUBLIC_URL later 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:CANCELLED for 30 days, then are dropped.
  • CATEGORIES from kind, an optional VALARM from the calendar's alert default, CRLF line endings, 75-octet line folding, and escaped commas, semicolons, and newlines. Don't rely on VALARM in 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 deadline items), 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 RRULE with 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 LOCATION off 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_tokens either 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.txt in 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.