From f80f8e80b0970361fe4b5f51d23394080ddd3a84 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 9 Nov 2026 13:31:18 -0600 Subject: [PATCH] Subscription feeds per calendar, calendar color in ICS, About section in Settings --- CHANGELOG.md | 6 +++ README.md | 2 + docs/SPEC.md | 2 +- frontend/src/lib/api.ts | 8 +++- frontend/src/routes/CalendarPage.svelte | 53 ++++++++++++++++++++++++- frontend/src/routes/SettingsPage.svelte | 21 ++++++++++ main.py | 10 ++++- parentcalendar/app.py | 32 ++++++++++++++- parentcalendar/config.py | 6 +++ parentcalendar/ics.py | 18 +++++++-- tests/test_phase1.py | 9 +++++ 11 files changed, 156 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 407e6e2..0b6c397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +### Added + +- Subscription feeds. The Calendar tab has Subscribe and Copy link for each calendar, so each family member's phone keeps itself up to date and gets the alerts (on iPhone, turn off "Remove Alerts" when subscribing). The feed is `/feed/.ics`, the only URL that needs no login: the past 12 months and everything ahead, deletions as cancelled for 30 days, with ETag and caching headers. "Make a new one" replaces a link that was shared by mistake. The access log hides the token. +- Downloads and feeds carry the calendar's color for Apple Calendar, and each event's DTSTAMP is when it last changed. +- An About section at the bottom of Settings with the name, version and commit (linked to the repository). + ## [0.0.6] - 2026-11-09 ### Changed diff --git a/README.md b/README.md index 85b321e..77f41ff 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Then open http://127.0.0.1:8000. Rebuild the front end after changing anything i **Docker:** copy `compose.example.yml` to `compose.yml`, then `docker compose up -d --build`. +**Subscription feeds:** each calendar has a feed at `PUBLIC_URL/feed/.ics` (Calendar tab → Subscribe). It's the one URL that needs no login, so your family's phones can reach it from anywhere: expose `/feed/` publicly on your reverse proxy and keep the rest of the app on your LAN or VPN. The token is the secret; "Make a new one" on the Calendar tab replaces it. The access log shows `/feed/***` instead of the token. + **Backups:** `python main.py backup ` copies the database (safely, while running) and the photos. Schedule it nightly. ## Develop diff --git a/docs/SPEC.md b/docs/SPEC.md index 25d96a0..9fa13b2 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -225,7 +225,7 @@ A menu isn't a list of events. It's something you go through with your kids, and 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. +3. **Sync.** Each picked day creates a confirmed event titled "Pack lunch — ", with kind `pack_lunch`, tagged with that child. It's 8:00–8:30 AM on the school day, with alerts 12 hours before and when it starts, and it reaches every calendar that includes that child, by feed or .ics, like any other event. **Rules** diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 475bebb..7ef6b91 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -3,14 +3,18 @@ export type Kind = 'no_school' | 'early_release' | 'event' | 'game' | 'practice' export interface Child { id: number; display_name: string; color: string } export interface Calendar { id: number; name: string; color: string; child_ids: number[]; source_ids: number[]; - alarm_minutes: number | null; last_exported_at: string | null; + alarm_minutes: number | null; last_exported_at: string | null; feed_token: string; } export interface Source { id: number; name: string; layout_type: 'month_grid' | 'menu' | 'list'; double_read: boolean; ignore_labels: string[]; applies_filter: string | null; prompt_notes: string | null; default_calendar_id: number | null; child_ids: number[]; } -export interface Household { timezone: string; show_scan_stats: boolean; children: Child[]; calendars: Calendar[]; sources: Source[] } +export interface Household { + timezone: string; show_scan_stats: boolean; public_url: string; + about: { version: string; commit: string; repo_url: string }; + children: Child[]; calendars: Calendar[]; sources: Source[]; +} export interface CalEvent { id: number; uid: string; scan_id: number | null; source_id: number | null; diff --git a/frontend/src/routes/CalendarPage.svelte b/frontend/src/routes/CalendarPage.svelte index 5f66f9d..4cbe3ea 100644 --- a/frontend/src/routes/CalendarPage.svelte +++ b/frontend/src/routes/CalendarPage.svelte @@ -4,13 +4,15 @@ import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronRight from '@lucide/svelte/icons/chevron-right'; import CircleAlert from '@lucide/svelte/icons/circle-alert'; + import Copy from '@lucide/svelte/icons/copy'; import Download from '@lucide/svelte/icons/download'; import FileDown from '@lucide/svelte/icons/file-down'; import LayoutGrid from '@lucide/svelte/icons/layout-grid'; import List from '@lucide/svelte/icons/list'; import MapPin from '@lucide/svelte/icons/map-pin'; + import Rss from '@lucide/svelte/icons/rss'; import { api, KINDS, type CalEvent, type EventValues } from '../lib/api'; - import { app, toast } from '../lib/state.svelte'; + import { app, setHousehold, toast } from '../lib/state.svelte'; import { iso, monthEnd, monthLabel, parse, shiftMonth, thisMonth, whenLabel } from '../lib/dates'; import { KIND_ICONS } from '../lib/icons'; import PageHeader from '../lib/ui/PageHeader.svelte'; @@ -50,6 +52,27 @@ return [...days.entries()].sort(); }); const today = iso(new Date()); + // The subscription feed: the one public URL, so it uses PUBLIC_URL. webcal:// opens the phone's subscribe screen. + const feedUrl = $derived(calendar ? `${app.household!.public_url}/feed/${calendar.feed_token}.ics` : ''); + const webcalUrl = $derived(feedUrl.replace(/^https?:/, 'webcal:')); + let confirmReset = $state(false); + async function copyFeed() { + try { + await navigator.clipboard.writeText(feedUrl); + toast('Link copied'); + } catch { + toast("Couldn't copy. Press and hold Subscribe to copy the link instead."); + } + } + async function resetFeed() { + try { + setHousehold(await api(`/calendars/${calendarId}/feed_token`, { method: 'POST' })); + confirmReset = false; + toast('New link made. Send it to everyone who subscribed.'); + } catch (e: any) { + toast(e.message); + } + } const lastDownload = $derived(calendar?.last_exported_at ? new Date(calendar.last_exported_at.replace(' ', 'T') + 'Z') : null); async function load() { @@ -104,11 +127,36 @@ +
+
+ +
+

Subscribe, for the whole family

+

Each phone that subscribes keeps itself up to date, alerts included. Send the link to your family. On iPhone, tap Subscribe, then turn off “Remove Alerts”.

+
+
+
+ + +
+ {#if confirmReset} + +
+ + +
+ {:else} + + {/if} +
+
-

Add to your phone's calendar

+

Or download a file

On iPhone, open the file and tap “Add All”. Each download holds only what changed since the last one, so nothing is duplicated.

@@ -190,6 +238,7 @@ .export-head { display: flex; gap: var(--s3); align-items: flex-start; } .export-icon { display: grid; place-items: center; width: 44px; height: 44px; flex: none; border-radius: var(--r-md); background: var(--primary-soft); color: var(--primary-text); } .export h2 { font-size: var(--fs-md); margin-bottom: 2px; } + .reset { align-self: flex-start; } .month-bar { display: flex; align-items: center; gap: var(--s1); } .month-name { font-size: var(--fs-xl); min-width: 10ch; text-align: center; } diff --git a/frontend/src/routes/SettingsPage.svelte b/frontend/src/routes/SettingsPage.svelte index b00cfd5..c29a97b 100644 --- a/frontend/src/routes/SettingsPage.svelte +++ b/frontend/src/routes/SettingsPage.svelte @@ -3,6 +3,7 @@ import Check from '@lucide/svelte/icons/check'; import ChevronRight from '@lucide/svelte/icons/chevron-right'; import Globe from '@lucide/svelte/icons/globe'; + import Info from '@lucide/svelte/icons/info'; import Monitor from '@lucide/svelte/icons/monitor'; import Moon from '@lucide/svelte/icons/moon'; import Palette from '@lucide/svelte/icons/palette'; @@ -303,10 +304,30 @@ {#if isEditing('source', null)}{@render sourceForm()}{:else}{/if}
+ +
+ {@render heading(Info, 'About', '')} +
+
Name
ParentCalendar
+
Version
{h.about.version}
+
+
Commit
+
+ {#if h.about.commit !== 'unknown'} + {h.about.commit} + {:else}{h.about.commit}{/if} +
+
+
+