From d3cb3f8d479313e31174368e826abef007e83ceb Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Mon, 9 Nov 2026 21:46:05 -0600 Subject: [PATCH] Federal holidays in the app, national days from Checkiday in the app and/or exports --- .env.example | 5 + CHANGELOG.md | 2 + README.md | 2 + deploy/compose-complete.yml | 2 + docs/SPEC.md | 3 +- frontend/src/app.css | 1 + frontend/src/lib/api.ts | 6 + frontend/src/routes/CalendarPage.svelte | 63 +++++++++- frontend/src/routes/MonthGrid.svelte | 27 ++++- frontend/src/routes/SettingsPage.svelte | 29 +++++ main.py | 1 + parentcalendar/app.py | 38 ++++-- parentcalendar/config.py | 1 + parentcalendar/db.py | 2 +- parentcalendar/holidays.py | 135 +++++++++++++++++++++ parentcalendar/ics.py | 8 +- parentcalendar/migrations/005_holidays.sql | 10 ++ parentcalendar/scans.py | 7 +- tests/test_phase1.py | 53 +++++++- 19 files changed, 375 insertions(+), 20 deletions(-) create mode 100644 parentcalendar/holidays.py create mode 100644 parentcalendar/migrations/005_holidays.sql diff --git a/.env.example b/.env.example index e8a5c0f..6fa9284 100644 --- a/.env.example +++ b/.env.example @@ -40,5 +40,10 @@ EXTRACT_MODEL=nemotron-3.5-bolt:e4b # Days to keep photos after a scan is published. IMAGE_RETENTION_DAYS=90 +# National days ("National Chicken Sandwich Day"), shown when Settings > Holidays asks for them. Get a free key +# (100 requests a month; this uses about 31) at https://marketplace.apilayer.com/checkiday-api. The free plan only +# gives today's list, so each day's arrives that morning; days before you set this aren't filled in. +# CHECKIDAY_API_KEY= + # 1 adds a "Scan Stats" button to each scan's page (model, tokens, pages, events, scan time), for comparing models. Every scan's time is logged either way. # SHOW_SCAN_STATS=0 diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f4994..e135fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - `phase0/extract.py` runs golden cases with a `"text"` file instead of an `"image"`. - `deploy/nginx.conf`, an example nginx site that makes only `/feed/` public and keeps the rest of the app on the LAN or VPN. - `deploy/compose-complete.yml`, an example Compose file that runs ParentCalendar and Ollama together (NVIDIA GPU, with AMD commented out), with every setting from `.env.example` and its default. +- Holidays in Settings. Federal holidays (all 11, with the weekday they're observed) show on the Calendar tab, on by default, and never go into downloads or subscriptions. National days such as National Chicken Sandwich Day come from Checkiday with a free `CHECKIDAY_API_KEY`, once a day: off by default, then in the app, in exports, or both. Exported, a day's national days are one all-day event ("National Fried Chicken Sandwich Day + 4 more", all of them in the notes), shown as free time and with no alert. The free plan only answers for today, so each day's list arrives that morning and days ahead aren't known. Needs database migration 005, which runs on start. ### Fixed +- An alert's icon no longer shrinks to a dot when its text is long. - A time, notes or place the model wrote as the word "null" is now empty instead of the text "null". ## [0.0.7] - 2026-11-09 diff --git a/README.md b/README.md index e1b73d4..69e2759 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Then open http://127.0.0.1:8000. Rebuild the front end after changing anything i **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 ([`deploy/nginx.conf`](deploy/nginx.conf) is an example). The token is the secret; "Make a new one" on the Calendar tab replaces it. The access log shows `/feed/***` instead of the token. +**Holidays:** Settings → Holidays shows federal holidays on the Calendar tab (never exported; phones already have them) and, with a free `CHECKIDAY_API_KEY`, national days like National Chicken Sandwich Day, in the app, in exports, or both. Checkiday's free plan only gives today's list, so each day's arrives that morning. + **Backups:** `python main.py backup ` copies the database (safely, while running) and the photos. Schedule it nightly. ## Develop diff --git a/deploy/compose-complete.yml b/deploy/compose-complete.yml index aa91ae7..bd489fd 100644 --- a/deploy/compose-complete.yml +++ b/deploy/compose-complete.yml @@ -35,6 +35,8 @@ services: - EXTRACT_DOUBLE_READ=${EXTRACT_DOUBLE_READ:-0} # Comma-separated labels that are never food or events, e.g. BHS. - EXTRACT_IGNORE=${EXTRACT_IGNORE:-} + # National days in Settings > Holidays: a free key from https://marketplace.apilayer.com/checkiday-api + - CHECKIDAY_API_KEY=${CHECKIDAY_API_KEY:-} # Days to keep photos after a scan is published. - IMAGE_RETENTION_DAYS=${IMAGE_RETENTION_DAYS:-90} # 1 adds a "Scan Stats" button to each scan's page. diff --git a/docs/SPEC.md b/docs/SPEC.md index 7d4cb24..8dc8220 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -189,7 +189,8 @@ The key decision is that every event has a permanent `uid`, created once and reu | Table | Key fields | Notes | | --- | --- | --- | -| `user` | id, email, timezone, created\_at | Timezone defaults from the browser | +| `user` | id, email, timezone, federal\_holidays (off, app), national\_days (off, app, export, both), created\_at | Timezone defaults from the browser. Federal holidays are worked out in code and never exported | +| `national_day` | date, names\[\] (Checkiday id, name, url), fetched\_at | One row per day, fetched from Checkiday that day (the free plan only answers for today). Exported as one all-day event per day, free time and no alerts | | `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 (of the text, for pasted text), text (pasted email; then no images), 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 | diff --git a/frontend/src/app.css b/frontend/src/app.css index c0aa0be..3bb4b37 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -262,6 +262,7 @@ textarea { min-height: 96px; resize: vertical; line-height: 1.5; } display: flex; gap: var(--s2); align-items: flex-start; padding: var(--s3) var(--s4); border-radius: var(--r-md); font-size: var(--fs-sm); } +.alert > svg { flex: none; } /* long text would squeeze the icon to a dot */ .alert.error { background: var(--danger-soft); color: var(--danger); } .alert.info { background: var(--info-soft); color: var(--info); } .alert.warn { background: var(--warn-soft); color: var(--warn); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0aaa6ea..a593582 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -13,9 +13,15 @@ export interface Source { export interface Household { timezone: string; show_scan_stats: boolean; public_url: string; about: { version: string; commit: string; repo_url: string }; + holidays: { federal_holidays: 'off' | 'app'; national_days: 'off' | 'app' | 'export' | 'both'; checkiday: boolean }; children: Child[]; calendars: Calendar[]; sources: Source[]; } +/** Shown in the app only, never edited: a federal holiday, or one day's national days (from Checkiday). */ +export type Observance = + | { kind: 'federal'; date: string; name: string; observed: string } + | { kind: 'national'; date: string; names: { id: string; name: string; url: string }[] }; + export interface CalEvent { id: number; uid: string; scan_id: number | null; source_id: number | null; date: string; end_date: string | null; all_day: boolean; start_time: string | null; end_time: string | null; diff --git a/frontend/src/routes/CalendarPage.svelte b/frontend/src/routes/CalendarPage.svelte index 4cbe3ea..1f5e403 100644 --- a/frontend/src/routes/CalendarPage.svelte +++ b/frontend/src/routes/CalendarPage.svelte @@ -5,13 +5,16 @@ import ChevronRight from '@lucide/svelte/icons/chevron-right'; import CircleAlert from '@lucide/svelte/icons/circle-alert'; import Copy from '@lucide/svelte/icons/copy'; + import ChevronDown from '@lucide/svelte/icons/chevron-down'; import Download from '@lucide/svelte/icons/download'; import FileDown from '@lucide/svelte/icons/file-down'; + import Flag from '@lucide/svelte/icons/flag'; import LayoutGrid from '@lucide/svelte/icons/layout-grid'; import List from '@lucide/svelte/icons/list'; import MapPin from '@lucide/svelte/icons/map-pin'; + import PartyPopper from '@lucide/svelte/icons/party-popper'; import Rss from '@lucide/svelte/icons/rss'; - import { api, KINDS, type CalEvent, type EventValues } from '../lib/api'; + import { api, KINDS, type CalEvent, type EventValues, type Observance } from '../lib/api'; 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'; @@ -26,6 +29,7 @@ let month = $state(thisMonth()); let view = $state<'list' | 'grid'>('list'); let events = $state(null); + let observances = $state([]); // holidays: shown here, never edited let editing = $state(null); let newDate = $state(''); let editDay = $state(''); // a multi-day event is listed on each day; its editor opens under the one tapped @@ -40,6 +44,7 @@ const colorFor = (e: CalEvent) => children.get(e.child_ids[0])?.color ?? calendar?.color ?? 'var(--primary)'; const byDay = $derived.by(() => { const days = new Map(); + for (const o of observances) if (!days.has(o.date)) days.set(o.date, []); // a holiday's day is listed even with no events const last = monthEnd(month); for (const e of events ?? []) { // A multi-day event shows on each of its days in this month. @@ -51,6 +56,8 @@ } return [...days.entries()].sort(); }); + const obsOn = (day: string) => observances.filter((o) => o.date === day); + const shortDay = (d: string) => parse(d).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); 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` : ''); @@ -80,7 +87,11 @@ error = ''; events = null; try { - events = await api(`/events?calendar_id=${calendarId}&start=${month}-01&end=${monthEnd(month)}`); + const range = `start=${month}-01&end=${monthEnd(month)}`; + [events, observances] = await Promise.all([ + api(`/events?calendar_id=${calendarId}&${range}`), + api(`/observances?${range}`), + ]); } catch (e: any) { error = e.message; } @@ -189,8 +200,8 @@ {#each [1, 2, 3] as _}
{/each} {:else if view === 'grid'} - - {:else if !events.length} + + {:else if !byDay.length}

Publish a scan, or add an event by hand.

@@ -207,6 +218,34 @@ {#if isToday}Today{/if}
    + {#each obsOn(day) as o (o.kind)} + {#if o.kind === 'federal'} +
  • + + + {o.name} + Federal holiday{#if o.observed !== o.date} · observed {shortDay(o.observed)}{/if} + +
  • + {:else} +
  • +
    + + + + {o.names[0].name}{#if o.names.length > 1}{' '}+ {o.names.length - 1} more{/if} + National days + + +
      + {#each o.names as n (n.id)}
    • {n.name}
    • {/each} +
    +

    From Checkiday

    +
    +
  • + {/if} + {/each} {#each list as e (e.id)} {@const KindIcon = KIND_ICONS[e.kind]}
  • @@ -260,4 +299,20 @@ .kind { display: grid; place-items: center; width: 36px; height: 36px; flex: none; border-radius: var(--r-md); background: var(--surface-2); color: var(--text-2); } .title { display: block; font-weight: 650; color: var(--text); } .meta { display: block; font-size: var(--fs-sm); color: var(--text-2); } + + /* Holidays: flatter than events (no child color, no shadow) since they can't be edited. */ + .obs { border: 1px dashed var(--border-strong); border-radius: var(--r-lg); padding: var(--s1) var(--s2); background: var(--surface-2); } + .obs > .kind, .obs summary .kind { background: var(--surface); } + .federal { display: flex; align-items: center; gap: var(--s3); min-height: 56px; padding: var(--s1) var(--s3); } + .federal .kind { color: var(--info); } + .national .kind { color: var(--warn); } + .national summary { display: flex; align-items: center; gap: var(--s3); min-height: 56px; padding: var(--s1); cursor: pointer; list-style: none; } + .national summary::-webkit-details-marker { display: none; } + .national summary:focus-visible { outline: 3px solid var(--primary); outline-offset: 2px; border-radius: var(--r-md); } + .national :global(.chev) { flex: none; color: var(--text-2); transition: transform var(--dur) var(--ease); } + .national details[open] :global(.chev) { transform: rotate(180deg); } + .more { font-weight: 500; color: var(--text-2); } + .names { list-style: none; margin: 0; padding: 0 var(--s2) var(--s1) calc(36px + var(--s4)); display: flex; flex-direction: column; } + .names a { display: block; padding: var(--s2) 0; min-height: 44px; } + .credit { padding: 0 var(--s2) var(--s2) calc(36px + var(--s4)); margin: 0; } diff --git a/frontend/src/routes/MonthGrid.svelte b/frontend/src/routes/MonthGrid.svelte index c3bbf9f..97e36f2 100644 --- a/frontend/src/routes/MonthGrid.svelte +++ b/frontend/src/routes/MonthGrid.svelte @@ -1,16 +1,20 @@