Subscription feeds per calendar, calendar color in ICS, About section in Settings

This commit is contained in:
iamdoubz committed 2026-11-09 13:31:18 -06:00
1 parent 061ddb8b01
commit f80f8e80b0
11 files changed
+156 -11

No files matched your search

+6
View File
@@ -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/<token>.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
+2
View File
@@ -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/<token>.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 <folder>` copies the database (safely, while running) and the photos. Schedule it nightly.
## Develop
+1 -1
View File
@@ -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 — <child>", 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 — <child>", 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**
+6 -2
View File
@@ -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;
+51 -2
View File
@@ -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 @@
<Segmented label="View" bind:value={view} options={[{ value: 'list', label: 'List', icon: List }, { value: 'grid', label: 'Month', icon: LayoutGrid }]} />
</div>
<section class="card export">
<div class="export-head">
<span class="export-icon"><Rss size={22} aria-hidden="true" /></span>
<div>
<h2>Subscribe, for the whole family</h2>
<p class="hint">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”.</p>
</div>
</div>
<div class="row">
<a class="btn primary" href={webcalUrl}><Rss size={18} aria-hidden="true" />Subscribe</a>
<button onclick={copyFeed}><Copy size={18} aria-hidden="true" />Copy link</button>
</div>
{#if confirmReset}
<div class="alert warn" role="alert">
<span>The old link stops working, so everyone has to subscribe again with the new one.</span>
</div>
<div class="row">
<button class="danger solid sm" onclick={resetFeed}>Make a new link</button>
<button class="ghost sm" onclick={() => (confirmReset = false)}>Keep this link</button>
</div>
{:else}
<button class="ghost sm reset" onclick={() => (confirmReset = true)}>Link shared by mistake? Make a new one…</button>
{/if}
</section>
<section class="card export">
<div class="export-head">
<span class="export-icon"><FileDown size={22} aria-hidden="true" /></span>
<div>
<h2>Add to your phone's calendar</h2>
<h2>Or download a file</h2>
<p class="hint">On iPhone, open the file and tap “Add All”. Each download holds only what changed since the last one, so nothing is duplicated.</p>
</div>
</div>
@@ -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; }
+21
View File
@@ -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 @@
</ul>
{#if isEditing('source', null)}{@render sourceForm()}{:else}<button class="add" onclick={() => edit('source', null)}><Plus size={18} aria-hidden="true" />Add a source</button>{/if}
</section>
<section class="card section-card">
{@render heading(Info, 'About', '')}
<dl class="about">
<div><dt>Name</dt><dd>ParentCalendar</dd></div>
<div><dt>Version</dt><dd class="tabular">{h.about.version}</dd></div>
<div>
<dt>Commit</dt>
<dd class="tabular">
{#if h.about.commit !== 'unknown'}
<a href={`${h.about.repo_url}/commit/${h.about.commit}`} target="_blank" rel="noopener">{h.about.commit}</a>
{:else}{h.about.commit}{/if}
</dd>
</div>
</dl>
</section>
</div>
<style>
.section-card { display: flex; flex-direction: column; gap: var(--s4); }
.about { display: flex; flex-direction: column; gap: var(--s2); margin: 0; }
.about div { display: flex; justify-content: space-between; gap: var(--s3); min-height: 32px; align-items: center; }
.about dt { color: var(--text-2); }
.about dd { margin: 0; font-weight: 600; }
.sec-head { display: flex; gap: var(--s3); align-items: flex-start; }
.sec-icon { display: grid; place-items: center; width: 40px; height: 40px; flex: none; border-radius: var(--r-md); background: var(--primary-soft); color: var(--primary-text); }
.sec-head h2 { font-size: var(--fs-md); }
+9 -1
View File
@@ -30,6 +30,13 @@ def backup(destination):
print(f"Backed up to {target}")
def hide_feed_tokens(record):
"""Feed URLs are bearer secrets, so the access log shows /feed/*** instead of the token."""
if isinstance(record.args, tuple) and len(record.args) >= 3 and str(record.args[2]).startswith("/feed/"):
record.args = (*record.args[:2], "/feed/***", *record.args[3:])
return True
def serve():
if not config.PUBLIC_URL:
sys.exit("Set PUBLIC_URL (for example https://calendar.example.com) in the environment or in .env.")
@@ -40,7 +47,7 @@ def serve():
from parentcalendar import extraction
env = os.environ.get
print(f"ParentCalendar {env('APP_VERSION', 'dev')} ({env('APP_COMMIT', 'unknown')}, {env('APP_COMMIT_DATE', 'unknown')})")
print(f"ParentCalendar {config.APP_VERSION} ({config.APP_COMMIT}, {config.APP_COMMIT_DATE})")
settings = { # what the app actually uses, defaults included
"PUBLIC_URL": config.PUBLIC_URL, "DATA_DIR": config.DATA_DIR, "HOST": config.HOST, "PORT": config.PORT,
"OLLAMA_URL": extraction.OLLAMA_URL, "EXTRACT_MODEL": extraction.MODEL, "EXTRACT_THINK": int(extraction.THINK),
@@ -57,6 +64,7 @@ def serve():
# The app's own log lines (scan timings, failures), formatted like uvicorn's. Other libraries stay at warnings.
logging.basicConfig(format="%(levelname)s: %(message)s")
logging.getLogger("parentcalendar").setLevel(logging.INFO)
logging.getLogger("uvicorn.access").addFilter(hide_feed_tokens)
uvicorn.run("parentcalendar.app:app", host=config.HOST, port=config.PORT, proxy_headers=True)
+31 -1
View File
@@ -1,4 +1,5 @@
"""HTTP API under /api, and the built front end (static/) for everything else."""
import hashlib
import json
import re
import secrets
@@ -9,7 +10,7 @@ from datetime import date, datetime, time, timedelta, timezone
from typing import Annotated, Literal
from zoneinfo import available_timezones
from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, field_validator
@@ -74,6 +75,8 @@ def state(conn: DB):
return {
"timezone": conn.execute("SELECT timezone FROM user WHERE id = 1").fetchone()[0],
"show_scan_stats": config.SHOW_SCAN_STATS,
"public_url": config.PUBLIC_URL,
"about": {"version": config.APP_VERSION, "commit": config.APP_COMMIT, "repo_url": config.REPO_URL},
"children": db.all_dicts(conn.execute("SELECT * FROM child ORDER BY id")),
"calendars": db.all_dicts(conn.execute(
f"SELECT c.*, {CHILD_IDS.format(table='calendar_child', key='calendar_id', alias='c')} FROM calendar c ORDER BY id")),
@@ -606,5 +609,32 @@ def export_ics(calendar_id: int, conn: DB, everything: bool = False):
headers={"Content-Disposition": f'attachment; filename="{filename}.ics"'})
@app.post("/api/calendars/{calendar_id}/feed_token")
def reset_feed_token(calendar_id: int, conn: DB):
"""A new feed link. The old one stops working, so everyone subscribed has to subscribe again."""
found(db.one(conn, "SELECT id FROM calendar WHERE id = ?", (calendar_id,)), "calendar")
with conn:
conn.execute("UPDATE calendar SET feed_token = ? WHERE id = ?", (secrets.token_urlsafe(24), calendar_id))
return state(conn)
@app.get("/feed/{token}.ics")
def feed(token: str, request: Request, conn: DB):
"""A calendar's subscription feed, the only URL that's public. The token in the path is the secret.
The past 12 months and everything ahead, with deletions as CANCELLED for CANCELLED_DAYS."""
calendar = found(db.one(conn, "SELECT * FROM calendar WHERE feed_token = ?", (token,)), "feed")
tz = conn.execute("SELECT timezone FROM user WHERE id = 1").fetchone()[0]
cutoff = (datetime.now(timezone.utc) - timedelta(days=config.CANCELLED_DAYS)).strftime("%Y-%m-%d %H:%M:%S")
start = (date.today() - timedelta(days=config.FEED_PAST_DAYS)).isoformat()
events = [e for e in calendar_events(conn, calendar, ["confirmed", "deleted"], start)
if e["status"] == "confirmed" or e["updated_at"] >= cutoff]
body = ics.build(calendar["name"], events, tz, calendar["alarm_minutes"], color=calendar["color"], refresh="PT1H")
etag = '"' + hashlib.sha256(body).hexdigest()[:32] + '"'
headers = {"ETag": etag, "Cache-Control": "private, max-age=900"}
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers=headers)
return Response(body, media_type="text/calendar; charset=utf-8", headers=headers)
if config.STATIC_DIR.exists():
app.mount("/", StaticFiles(directory=config.STATIC_DIR, html=True), name="static")
+6
View File
@@ -27,6 +27,12 @@ HOST = os.environ.get("HOST", "127.0.0.1")
PORT = int(os.environ.get("PORT", "8000"))
IMAGE_RETENTION_DAYS = int(os.environ.get("IMAGE_RETENTION_DAYS", "90"))
SHOW_SCAN_STATS = os.environ.get("SHOW_SCAN_STATS", "").strip().lower() in ("1", "true", "yes") # model and time on scan pages
# Build info, set by the Docker image (see Dockerfile); shown in Settings → About.
APP_VERSION = os.environ.get("APP_VERSION", "dev")
APP_COMMIT = os.environ.get("APP_COMMIT", "unknown")
APP_COMMIT_DATE = os.environ.get("APP_COMMIT_DATE", "unknown")
REPO_URL = "https://git.dou.bet/iamdoubz/parentcalendar"
FEED_PAST_DAYS = 365 # feeds carry the past 12 months plus everything ahead
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # per file
CANCELLED_DAYS = 30 # deleted events stay in exports as STATUS:CANCELLED this long
# Stored in each new event's UID; changing PUBLIC_URL later never changes existing UIDs.
+14 -4
View File
@@ -14,20 +14,26 @@ def utc(stamp):
return datetime.fromisoformat(stamp).replace(tzinfo=timezone.utc)
def build(name, events, tz_name, alarm_minutes=None):
"""events: dicts from the event table (status confirmed or deleted). Returns .ics bytes."""
def build(name, events, tz_name, alarm_minutes=None, color=None, refresh=None):
"""events: dicts from the event table (status confirmed or deleted). Returns .ics bytes.
color: the calendar's hex color, which Apple uses when subscribing. refresh: how often a feed asks to be fetched."""
cal = Calendar()
cal.add("prodid", PRODID)
cal.add("version", "2.0")
cal.add("calscale", "GREGORIAN")
cal.add("x-wr-calname", name)
cal.add("x-wr-timezone", tz_name)
if color:
cal.add("x-apple-calendar-color", color)
if refresh:
cal["REFRESH-INTERVAL"] = vInline(refresh)
cal["REFRESH-INTERVAL"].params["VALUE"] = "DURATION"
cal.add("x-published-ttl", refresh)
zone = ZoneInfo(tz_name)
now = datetime.now(timezone.utc).replace(microsecond=0)
for e in events:
ev = Event()
ev.add("uid", e["uid"])
ev.add("dtstamp", now)
ev.add("dtstamp", utc(e["updated_at"])) # with no METHOD, DTSTAMP is when the event last changed
ev.add("summary", e["title"])
start = date.fromisoformat(e["date"])
end = date.fromisoformat(e["end_date"]) if e.get("end_date") else start
@@ -72,6 +78,10 @@ if __name__ == "__main__":
ev(uid="e@x", title="Spirit Day", status="deleted", sequence=2),
ev(uid="f@x", title="Pack lunch", kind="pack_lunch", all_day=False, start_time="08:00", end_time="08:30", date="2026-11-11"),
], "America/Chicago", alarm_minutes=60).decode()
feed = build("Kids", [ev()], "America/Chicago", color="#2c0977", refresh="PT1H")
assert feed == build("Kids", [ev()], "America/Chicago", color="#2c0977", refresh="PT1H") # same events, same bytes (ETag)
feed = feed.decode()
assert "X-APPLE-CALENDAR-COLOR:#2c0977" in feed and "REFRESH-INTERVAL;VALUE=DURATION:PT1H" in feed and "X-PUBLISHED-TTL:PT1H" in feed
assert "\r\n" in out and "VERSION:2.0" in out and "X-WR-TIMEZONE:America/Chicago" in out
assert "DTSTART;VALUE=DATE:20261030" in out and "DTEND;VALUE=DATE:20261031" in out # exclusive end
assert "DTSTART;VALUE=DATE:20261221" in out and "DTEND;VALUE=DATE:20270102" in out # break runs through Jan 1
+9
View File
@@ -122,6 +122,15 @@ def test():
assert "STATUS:CANCELLED" in c.get(f"/api/calendars/{cal}/export.ics").text
assert c.get(f"/api/calendars/{cal}/export.ics?everything=true").text.count("BEGIN:VEVENT") == 4
# Feed: everything plus the deletion, with an ETag; a reset link retires the old one
token = c.get("/api/state").json()["calendars"][0]["feed_token"]
r = c.get(f"/feed/{token}.ics")
assert r.status_code == 200 and r.text.count("BEGIN:VEVENT") == 5 and "STATUS:CANCELLED" in r.text
assert c.get(f"/feed/{token}.ics", headers={"If-None-Match": r.headers["etag"]}).status_code == 304
assert c.get("/feed/nope.ics").status_code == 404
new = c.post(f"/api/calendars/{cal}/feed_token").json()["calendars"][0]["feed_token"]
assert new != token and c.get(f"/feed/{token}.ics").status_code == 404 and c.get(f"/feed/{new}.ics").status_code == 200
# A revised calendar: the matching event keeps its UID and is updated, not duplicated
spirit = next(e for e in scan["events"] if e["title"] == "Spirit Day" and e["date"] == "2026-10-02")
rescan = upload(c, district, png("gray"), {"events": [