16 Commits
Author SHA1 Message Date
iamdoubz bb47aff609 Merge pull request 'fix/calendar-no-child' (#7) from published-events into main
Release image / image (release) Successful in 5s
Reviewed-on: #7
2026-11-06 14:42:59 -06:00
iamdoubz 415d26c33b Update changelog for 0.0.5 2026-11-06 14:41:08 -06:00
iamdoubz c6b3d6c0af List and edit a published scan's events; new sources start with every child 2026-11-06 14:40:21 -06:00
iamdoubz b3ac884707 Merge pull request 'feat/ui-today-stats' (#6) from published-photo into main
Release image / image (release) Successful in 4s
Reviewed-on: #6
2026-11-06 11:51:48 -06:00
iamdoubz 3a7d796f30 Show scan stats behind a Scan Stats button 2026-11-06 11:50:06 -06:00
iamdoubz cb9a611aaa Log each scan's processing time and add SHOW_SCAN_STATS 2026-11-06 11:44:14 -06:00
iamdoubz ad6cd4bbfd Update changelog for 0.0.4 2026-11-06 11:37:19 -06:00
iamdoubz a953b1bcf4 Highlight today in the Calendar list 2026-11-06 11:36:43 -06:00
iamdoubz 2c3867b1c8 Highlight today on the Lunch screen 2026-11-06 11:30:10 -06:00
iamdoubz d600332af4 Show the scanned photo on a published scan 2026-11-06 11:25:00 -06:00
iamdoubz 6eed7aaa60 Merge pull request 'Ollama settings' (#5) from ollama-settings into main
Release image / image (release) Successful in 3s
Reviewed-on: #5
2026-11-06 11:02:23 -06:00
iamdoubz fa5dc94293 Update changelog for 0.0.3 2026-11-06 11:01:25 -06:00
iamdoubz 9b1a0a99f2 Add Ollama top P, top K, thinking level, context size and keep-alive settings 2026-11-06 11:00:45 -06:00
iamdoubz fffbcbe293 Merge pull request 'Put the day's first menu line in Pack lunch notes' (#4) from pack-lunch-notes into main
Reviewed-on: #4
2026-11-06 09:31:15 -06:00
iamdoubz abbcc377ff Put the day's first menu line in Pack lunch notes 2026-11-06 09:30:49 -06:00
iamdoubz ea541e4a3e Print version and settings when the server starts 2026-11-06 09:23:59 -06:00
18 changed files with 291 additions and 21 deletions

No files matched your search

+21
View File
@@ -16,8 +16,29 @@ OLLAMA_URL=http://localhost:11434
EXTRACT_MODEL=nemotron-3.5-bolt:e4b
# ANTHROPIC_API_KEY=
# Ollama settings. Commented-out lines show the default.
# Sampling: leave top P and top K unset to use the model's own.
# EXTRACT_TEMPERATURE=0
# EXTRACT_TOP_P=0.9
# EXTRACT_TOP_K=40
# Context size in tokens; raise it if a dense page or thinking runs out of room.
# EXTRACT_NUM_CTX=16384
# How long Ollama keeps the model loaded after a scan: 10m, 1h, 0 to unload right away, -1 forever.
# EXTRACT_KEEP_ALIVE=10m
# 1 lets the model reason before answering (slower, needs more context).
# EXTRACT_THINK=0
# Only with EXTRACT_THINK=1, for models that have levels: low, medium or high.
# EXTRACT_THINK_LEVEL=medium
# 1 reads every page twice and flags where the reads disagree, for the command-line eval in phase0/.
# In the app, each source's "double read" setting decides instead.
# EXTRACT_DOUBLE_READ=0
# Labels that are never food or events, for every source (each source can add its own).
# EXTRACT_IGNORE=BHS
# Days to keep photos after a scan is published.
IMAGE_RETENTION_DAYS=90
# 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
+3 -1
View File
@@ -38,5 +38,7 @@ jobs:
image="$registry/${REPO,,}" # registry paths must be lowercase
echo "$REGISTRY_TOKEN" | docker login "$registry" -u "$OWNER" --password-stdin
docker build --label "org.opencontainers.image.version=$version" "${tags[@]/#/--tag=$image:}" .
docker build --label "org.opencontainers.image.version=$version" \
--build-arg "VERSION=$version" --build-arg "COMMIT=$(git rev-parse --short HEAD)" \
--build-arg "COMMIT_DATE=$(git log -1 --format=%cs)" "${tags[@]/#/--tag=$image:}" .
for t in "${tags[@]}"; do docker push "$image:$t"; done
+30 -1
View File
@@ -5,6 +5,32 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
## [Unreleased]
## [0.0.5] - 2026-11-06
### Added
- A published scan's page lists its events, and you can edit or delete them there like on the Calendar tab. Events with no child picked are on no calendar, so they're marked "Not on any calendar" there, with a note to pick a child.
### Changed
- A new source starts with every child ticked, like a new calendar. Before, it started with none, and its events showed on no calendar.
## [0.0.4] - 2026-11-06
### Added
- A published scan's photo can still be viewed from its scan page, under "Scanned photo", until it's deleted after `IMAGE_RETENTION_DAYS`.
- A log line for every scan with how long it took, the model and what it found. `SHOW_SCAN_STATS=1` adds a "Scan Stats" button next to a scan's status that shows the model, tokens used, pages, events and scan time.
- Today stands out on the Lunch screen and in the Calendar list: a filled day number like the month view, a "Today" label and a tinted card.
## [0.0.3] - 2026-11-06
### Added
- The server prints its version, commit and commit date, then its settings, when it starts (the API key only as set or unset).
- Ollama settings `EXTRACT_TOP_P`, `EXTRACT_TOP_K`, `EXTRACT_THINK_LEVEL` (low, medium or high, with `EXTRACT_THINK=1`), `EXTRACT_NUM_CTX` (default 16384) and `EXTRACT_KEEP_ALIVE` (default 10m).
- A "Pack lunch" event's notes are the day's first menu line, for example "Orange Chicken".
## [0.0.2] - 2026-11-06
### Added
@@ -40,6 +66,9 @@ First test release: the Phase 1 MVP.
- Runs with plain Python or as a Docker image, with SQLite and a `backup` command.
- Release workflow that pushes the image to the Gitea container registry: stable releases tag `X.Y.Z`, `X.Y`, `X` and `latest`; pre-releases tag `X.Y.Z` and `dev`.
[Unreleased]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.2...main
[Unreleased]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.5...main
[0.0.5]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.4...v0.0.5
[0.0.4]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.3...v0.0.4
[0.0.3]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.2...v0.0.3
[0.0.2]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.1...v0.0.2
[0.0.1]: https://git.dou.bet/iamdoubz/parentcalendar/src/tag/v0.0.1
+3
View File
@@ -19,4 +19,7 @@ RUN useradd --system --uid 1000 parentcalendar && mkdir -p /data && chown parent
USER parentcalendar
VOLUME /data
EXPOSE 8000
# Set by the release workflow; printed when the server starts. Last so a new version reuses the cached layers.
ARG VERSION=dev COMMIT=unknown COMMIT_DATE=unknown
ENV APP_VERSION=$VERSION APP_COMMIT=$COMMIT APP_COMMIT_DATE=$COMMIT_DATE
CMD ["python", "main.py"]
+6
View File
@@ -96,10 +96,16 @@ Both include `python main.py backup <destination>`, which runs SQLite's online b
| `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**
+2 -2
View File
@@ -10,7 +10,7 @@ export interface Source {
ignore_labels: string[]; applies_filter: string | null; prompt_notes: string | null;
default_calendar_id: number | null; child_ids: number[];
}
export interface Household { timezone: string; children: Child[]; calendars: Calendar[]; sources: Source[] }
export interface Household { timezone: string; show_scan_stats: boolean; children: Child[]; calendars: Calendar[]; sources: Source[] }
export interface CalEvent {
id: number; uid: string; scan_id: number | null; source_id: number | null;
@@ -32,7 +32,7 @@ export interface Scan {
id: number; source_id: number; source_name: string; layout_type: Source['layout_type']; hint_month: string;
date_start: string | null; date_end: string | null; image_keys: string[];
status: 'queued' | 'parsed' | 'failed' | 'abandoned' | 'reviewed' | 'published';
error: string | null; seconds: number | null; created_at: string; published_at: string | null;
error: string | null; seconds: number | null; model: string | null; cost_tokens: number | null; created_at: string; published_at: string | null;
events: CalEvent[]; menu_items: MenuItem[]; can_undo: boolean;
}
export interface ScanSummary {
+10 -2
View File
@@ -11,7 +11,7 @@
import MapPin from '@lucide/svelte/icons/map-pin';
import { api, KINDS, type CalEvent, type EventValues } from '../lib/api';
import { app, toast } from '../lib/state.svelte';
import { monthEnd, monthLabel, parse, shiftMonth, thisMonth, whenLabel } from '../lib/dates';
import { iso, monthEnd, monthLabel, parse, shiftMonth, thisMonth, whenLabel } from '../lib/dates';
import { KIND_ICONS } from '../lib/icons';
import PageHeader from '../lib/ui/PageHeader.svelte';
import EmptyState from '../lib/ui/EmptyState.svelte';
@@ -43,6 +43,7 @@
}
return [...days.entries()].sort();
});
const today = iso(new Date());
const lastDownload = $derived(calendar?.last_exported_at ? new Date(calendar.last_exported_at.replace(' ', 'T') + 'Z') : null);
async function load() {
@@ -143,11 +144,14 @@
<ol class="agenda">
{#each byDay as [day, list] (day)}
{@const d = parse(day)}
<li class="day">
{@const isToday = day === today}
<li class="day" class:today={isToday} aria-current={isToday ? 'date' : undefined}>
<div class="date" aria-hidden="true">
<span class="dow">{d.toLocaleDateString(undefined, { weekday: 'short' })}</span>
<span class="dnum tabular">{d.getDate()}</span>
{#if isToday}<span class="today-tag">Today</span>{/if}
</div>
{#if isToday}<span class="visually-hidden">Today</span>{/if}
<ul class="card-list grow">
{#each list as e (e.id)}
{@const KindIcon = KIND_ICONS[e.kind]}
@@ -189,6 +193,10 @@
.date { flex: none; width: 52px; display: flex; flex-direction: column; align-items: center; padding-top: var(--s2); }
.dow { font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-2); }
.dnum { font-size: var(--fs-xl); font-weight: 750; line-height: 1.1; }
/* Today: same filled day number and label as the Lunch screen, and its events get the same tint. */
.today .dnum { background: var(--primary); color: var(--on-primary); border-radius: var(--r-full); padding: 0 var(--s2); min-width: 2ch; text-align: center; margin-top: 2px; }
.today-tag { margin-top: var(--s1); font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--primary-text); }
.today .event { background: color-mix(in srgb, var(--primary-soft) 60%, var(--surface)); border-color: color-mix(in srgb, var(--primary) 35%, var(--border)); border-left-color: var(--child); }
.event { background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--child); border-radius: var(--r-lg); padding: var(--s1) var(--s2) var(--s1) var(--s2); box-shadow: var(--shadow-1); }
.item { width: 100%; min-height: 56px; justify-content: flex-start; gap: var(--s3); padding: var(--s1); border: 0; background: none; text-align: left; white-space: normal; font-weight: 400; }
+11 -3
View File
@@ -10,7 +10,7 @@
import Utensils from '@lucide/svelte/icons/utensils';
import { api, type LunchDay } from '../lib/api';
import { app, toast } from '../lib/state.svelte';
import { monthLabel, parse, shiftMonth, thisMonth } from '../lib/dates';
import { iso, monthLabel, parse, shiftMonth, thisMonth } from '../lib/dates';
import { foodEmoji } from '../lib/foodEmoji';
import PageHeader from '../lib/ui/PageHeader.svelte';
import EmptyState from '../lib/ui/EmptyState.svelte';
@@ -31,6 +31,7 @@
const open = $derived(days?.filter((d) => !d.greyed) ?? []); // days that can be picked
const allPicked = $derived(open.length > 0 && open.every((d) => d.picked));
let bulkBusy = $state(false);
const today = iso(new Date());
async function load() {
if (childId === null) return;
@@ -127,13 +128,15 @@
<ul class="days">
{#each days as d (d.date)}
{@const date = parse(d.date)}
<li class="day card" class:closed={d.greyed} class:picked={d.picked}>
{@const isToday = d.date === today}
<li class="day card" class:closed={d.greyed} class:picked={d.picked} class:today={isToday} aria-current={isToday ? 'date' : undefined}>
<div class="day-head">
<span class="date-tile" aria-hidden="true">
<span class="dow">{date.toLocaleDateString(undefined, { weekday: 'short' })}</span>
<span class="dnum tabular">{date.getDate()}</span>
{#if isToday}<span class="today-tag">Today</span>{/if}
</span>
<span class="visually-hidden">{date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}</span>
<span class="visually-hidden">{isToday ? 'Today, ' : ''}{date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}</span>
<span class="head-end">
{#if d.warning}<span class="badge warn"><TriangleAlert size={12} aria-hidden="true" />{d.warning}</span>{/if}
<!-- Decoration only: the foods are listed below, so screen readers skip this. -->
@@ -177,6 +180,11 @@
.day { display: flex; flex-direction: column; gap: var(--s3); transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); }
.day.picked { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary), var(--shadow-1); }
.day.closed { background: var(--day-closed); box-shadow: none; }
/* Today: the same filled day number as the month view, plus a label so it isn't color alone. */
.day.today:not(.closed) { background: color-mix(in srgb, var(--primary-soft) 60%, var(--surface)); }
.today .dnum { background: var(--primary); color: var(--on-primary); border-radius: var(--r-full); padding: 0 var(--s2); min-width: 2ch; text-align: center; }
.today .date-tile { align-items: center; }
.today-tag { font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--primary-text); }
.day-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s2); }
.date-tile { display: flex; align-items: baseline; gap: 6px; }
.head-end { display: flex; align-items: center; gap: var(--s2); min-width: 0; }
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import CalendarDays from '@lucide/svelte/icons/calendar-days';
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
import { api, KINDS, type EventValues, type Scan } from '../lib/api';
import { app, toast } from '../lib/state.svelte';
import { whenLabel } from '../lib/dates';
import { KIND_ICONS } from '../lib/icons';
import EventEditor from './EventEditor.svelte';
// A published scan's events, editable like on the Calendar tab. An event with no child is on no calendar,
// so this is the only place it can be found and fixed.
let { scan = $bindable() }: { scan: Scan } = $props();
let editing = $state<number | null>(null);
const events = $derived(scan.events.filter((e) => e.status === 'confirmed'));
const untagged = $derived(events.filter((e) => !e.child_ids.length).length);
const children = $derived(new Map(app.household!.children.map((c) => [c.id, c])));
async function run(work: () => Promise<unknown>, done: string) {
try {
await work();
editing = null;
scan = await api<Scan>(`/scans/${scan.id}`);
toast(done);
} catch (e: any) {
toast(e.message);
}
}
const save = (id: number) => (v: EventValues) => run(() => api(`/events/${id}`, { method: 'PATCH', json: v }), 'Saved');
const remove = (id: number) => () => run(() => api(`/events/${id}`, { method: 'DELETE' }), 'Deleted');
</script>
{#if events.length}
<section class="section fix">
<h2 class="section-title">
<CalendarDays size={16} aria-hidden="true" />Fix the events
<span class="count">{events.length} {events.length === 1 ? 'event' : 'events'}</span>
</h2>
{#if untagged}
<div class="alert warn" role="status">
<TriangleAlert size={18} aria-hidden="true" />
<span>{untagged === 1 ? '1 event has' : `${untagged} events have`} no child picked, so {untagged === 1 ? "it isn't" : "they aren't"} on any calendar. Tap one and pick a child.</span>
</div>
{:else}
<p class="hint">Tap an event to change it. Changes show on the Calendar tab as soon as you save.</p>
{/if}
<ul class="card-list">
{#each events as e (e.id)}
{@const KindIcon = KIND_ICONS[e.kind]}
{@const none = !e.child_ids.length}
<li class="event" class:none style:--child={children.get(e.child_ids[0])?.color ?? 'var(--warn)'}>
<button class="item" onclick={() => (editing = editing === e.id ? null : e.id)} aria-expanded={editing === e.id}>
<span class="kind"><KindIcon size={18} aria-hidden="true" /></span>
<span class="grow">
<span class="title">{e.title}</span>
<span class="meta">{whenLabel(e)} · {KINDS[e.kind]}</span>
{#if none}<span class="meta warn"><TriangleAlert size={12} aria-hidden="true" /> Not on any calendar</span>{/if}
</span>
</button>
{#if editing === e.id}
<EventEditor event={e} onsave={save(e.id)} oncancel={() => (editing = null)} ondelete={remove(e.id)} />
{/if}
</li>
{/each}
</ul>
</section>
{/if}
<style>
.fix { margin-top: var(--s6); }
/* Same rows as the Calendar list. */
.event { background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--child); border-radius: var(--r-lg); padding: var(--s1) var(--s2); box-shadow: var(--shadow-1); }
.event.none { border-left-style: dashed; }
.item { width: 100%; min-height: 56px; justify-content: flex-start; gap: var(--s3); padding: var(--s1); border: 0; background: none; text-align: left; white-space: normal; font-weight: 400; }
.item:hover:not(:disabled) { background: none; }
.item:active:not(:disabled) { transform: none; }
.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); }
.meta.warn { display: flex; align-items: center; gap: var(--s1); color: var(--warn); font-weight: 600; }
</style>
+60 -1
View File
@@ -1,8 +1,11 @@
<script lang="ts">
import CalendarDays from '@lucide/svelte/icons/calendar-days';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import ChartColumn from '@lucide/svelte/icons/chart-column';
import CircleAlert from '@lucide/svelte/icons/circle-alert';
import CircleCheck from '@lucide/svelte/icons/circle-check';
import Download from '@lucide/svelte/icons/download';
import ImageIcon from '@lucide/svelte/icons/image';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Sandwich from '@lucide/svelte/icons/sandwich';
@@ -13,6 +16,7 @@
import PageHeader from '../lib/ui/PageHeader.svelte';
import StatusBadge from './StatusBadge.svelte';
import MenuDays from './MenuDays.svelte';
import ScanEvents from './ScanEvents.svelte';
import Review from './Review.svelte';
let { id }: { id: number } = $props();
@@ -20,6 +24,9 @@
let error = $state('');
let confirmDiscard = $state(false);
let retrying = $state(false);
let statsOpen = $state(false);
// SHOW_SCAN_STATS: model, tokens and time, for comparing models. Shown once the scan has been read.
const hasStats = $derived(app.household!.show_scan_stats && scan?.model != null && scan?.seconds != null);
async function load() {
try {
@@ -75,8 +82,26 @@
</div>
{:else}
<PageHeader title={scan.source_name} subtitle={monthLabel(scan.hint_month)} back="#/">
{#snippet actions()}<StatusBadge status={scan!.status} />{/snippet}
{#snippet actions()}
<StatusBadge status={scan!.status} />
{#if hasStats}
<button class="sm" aria-expanded={statsOpen} aria-controls="scan-stats" onclick={() => (statsOpen = !statsOpen)}>
<ChartColumn size={16} aria-hidden="true" />Scan Stats
</button>
{/if}
{/snippet}
</PageHeader>
{#if hasStats && statsOpen}
<dl id="scan-stats" class="card stats tabular">
<div class="wide"><dt>Model</dt><dd>{scan.model}</dd></div>
<div><dt>Tokens used</dt><dd>{scan.cost_tokens?.toLocaleString() ?? '—'}</dd></div>
<!-- Photos are deleted after IMAGE_RETENTION_DAYS, and the page count with them. -->
<div><dt>Pages</dt><dd>{scan.image_keys.length || '—'}</dd></div>
<div><dt>Events</dt><dd>{scan.events.length}</dd></div>
{#if scan.menu_items.length}<div><dt>Menu days</dt><dd>{scan.menu_items.length}</dd></div>{/if}
<div><dt>Scan time</dt><dd>{scan.seconds!.toFixed(1)} s</dd></div>
</dl>
{/if}
{#if scan.status === 'queued'}
<section class="card state" aria-live="polite">
@@ -119,6 +144,27 @@
<a class="btn ghost" href="#/calendar"><CalendarDays size={18} aria-hidden="true" /> See the calendar</a>
</div>
</section>
{#if scan.image_keys.length}
<!-- Closed by default; the lazy images only load once it's opened. -->
<details class="card photos">
<summary>
<ImageIcon size={18} aria-hidden="true" />
<span class="grow">{scan.image_keys.length === 1 ? 'Scanned photo' : `Scanned photos (${scan.image_keys.length})`}</span>
<ChevronDown size={20} class="chev" aria-hidden="true" />
</summary>
<div class="photo-list">
{#each scan.image_keys as _, i}
<a href={`/api/scans/${scan.id}/pages/${i + 1}`} target="_blank" rel="noopener">
<img loading="lazy" src={`/api/scans/${scan.id}/pages/${i + 1}`} alt={`Page ${i + 1} of the scanned ${scan.source_name}`} />
</a>
{/each}
<p class="hint">Tap the photo to open it full size and zoom in.</p>
</div>
</details>
{:else}
<p class="hint photos-gone">The photo was deleted after the retention period.</p>
{/if}
<ScanEvents bind:scan />
{#if scan.menu_items.length}<div class="menu-fix"><MenuDays bind:scan /></div>{/if}
{:else}
<Review bind:scan />
@@ -141,6 +187,19 @@
.state-icon.danger { background: var(--danger-soft); color: var(--danger); }
.state p { max-width: 48ch; }
.actions { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--s2); margin-top: var(--s2); }
.photos { margin-top: var(--s4); padding: 0; overflow: hidden; }
.photos summary { display: flex; align-items: center; gap: var(--s2); min-height: 48px; padding: 0 var(--s4); cursor: pointer; font-weight: 600; list-style: none; }
.photos summary::-webkit-details-marker { display: none; }
.photos :global(.chev) { color: var(--text-2); transition: transform var(--dur) var(--ease); }
.photos[open] :global(.chev) { transform: rotate(180deg); }
.photo-list { display: flex; flex-direction: column; gap: var(--s3); padding: 0 var(--s4) var(--s4); }
.photo-list a { display: block; border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden; background: var(--surface-2); }
.photo-list img { display: block; width: 100%; height: auto; }
.photos-gone { margin-top: var(--s4); text-align: center; }
.menu-fix { margin-top: var(--s6); }
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--s3) var(--s4); margin: calc(-1 * var(--s2)) 0 var(--s4); }
.stats dt { font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-2); }
.stats dd { margin: 0; font-weight: 600; overflow-wrap: anywhere; }
.stats .wide { grid-column: 1 / -1; }
.discard { margin-top: var(--s6); padding-top: var(--s4); border-top: 1px solid var(--border); display: flex; gap: var(--s2); }
</style>
+1 -1
View File
@@ -56,7 +56,7 @@
source = { name: s.name ?? '', layout_type: s.layout_type ?? 'month_grid', double_read: s.double_read ?? false,
ignore_text: (s.ignore_labels ?? []).join(', '), applies_filter: s.applies_filter ?? '',
prompt_notes: s.prompt_notes ?? '', default_calendar_id: s.default_calendar_id ?? null,
child_ids: [...(s.child_ids ?? [])] };
child_ids: [...(s.child_ids ?? h.children.map((k) => k.id))] };
}
}
+22
View File
@@ -4,6 +4,8 @@ Usage:
python main.py start the server (settings: see .env.example)
python main.py backup <destination> copy the database (SQLite online backup) and the photos
"""
import logging
import os
import shutil
import sqlite3
import sys
@@ -35,6 +37,26 @@ def serve():
sys.exit("The front end isn't built yet. Run: cd frontend && npm ci && npm run build")
import uvicorn
from parentcalendar import extraction
env = os.environ.get
print(f"ParentCalendar {env('APP_VERSION', 'dev')} ({env('APP_COMMIT', 'unknown')}, {env('APP_COMMIT_DATE', 'unknown')})")
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),
"EXTRACT_THINK_LEVEL": extraction.THINK_LEVEL or "", "EXTRACT_TEMPERATURE": extraction.TEMPERATURE,
"EXTRACT_TOP_P": "" if extraction.TOP_P is None else extraction.TOP_P,
"EXTRACT_TOP_K": "" if extraction.TOP_K is None else extraction.TOP_K,
"EXTRACT_NUM_CTX": extraction.NUM_CTX, "EXTRACT_KEEP_ALIVE": extraction.KEEP_ALIVE, "EXTRACT_DOUBLE_READ": int(extraction.DOUBLE_READ),
"EXTRACT_IGNORE": ",".join(sorted(extraction.IGNORE)), "IMAGE_RETENTION_DAYS": config.IMAGE_RETENTION_DAYS,
"SHOW_SCAN_STATS": int(config.SHOW_SCAN_STATS),
"ANTHROPIC_API_KEY": "set" if env("ANTHROPIC_API_KEY") else "unset", # never print the key
}
print("Settings: " + " ".join(f"{k}={v}" for k, v in settings.items()))
# 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)
uvicorn.run("parentcalendar.app:app", host=config.HOST, port=config.PORT, proxy_headers=True)
+9 -4
View File
@@ -73,6 +73,7 @@ CHILD_IDS = "(SELECT json_group_array(child_id) FROM {table} WHERE {key} = {alia
def state(conn: DB):
return {
"timezone": conn.execute("SELECT timezone FROM user WHERE id = 1").fetchone()[0],
"show_scan_stats": config.SHOW_SCAN_STATS,
"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")),
@@ -541,6 +542,7 @@ class PicksIn(BaseModel):
def set_pick(conn, child, day, picked):
"""A pick is a 'Pack lunch' event for that child: picking creates or restores it (same UID), unpicking deletes it.
The event's notes are the menu's first line (the main dish), so the calendar shows what's being skipped.
Returns False when picking a day with no school lunch (no menu, no school, or no lunch)."""
entry = next(iter(lunch_days(conn, child["id"], day, day)), None)
if picked and (entry is None or entry["greyed"]):
@@ -548,13 +550,16 @@ def set_pick(conn, child, day, picked):
existing = db.one(conn, "SELECT e.* FROM event e JOIN event_child ec ON ec.event_id = e.id WHERE ec.child_id = ?"
" AND e.kind = 'pack_lunch' AND e.date = ? ORDER BY e.id LIMIT 1", (child["id"], day.isoformat()))
status = "confirmed" if picked else "deleted"
notes = next((m["items"][0] for m in entry["menus"] if m["items"]), None) if entry else None
if existing and existing["status"] != status:
conn.execute("UPDATE event SET status = ?, sequence = sequence + 1, updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now')"
" WHERE id = ?", (status, existing["id"]))
# Re-picking refreshes the notes from the menu, in case it was fixed since.
conn.execute("UPDATE event SET status = ?, notes = COALESCE(?, notes), sequence = sequence + 1,"
" updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = ?", (status, notes if picked else None, existing["id"]))
elif not existing and picked:
event_id = conn.execute(
"INSERT INTO event (uid, source_id, date, all_day, title, kind, status) VALUES (?, ?, ?, 1, ?, 'pack_lunch', 'confirmed')",
(scans.new_uid(), entry["menus"][0]["source_id"], day.isoformat(), f"Pack lunch — {child['display_name']}")).lastrowid
"INSERT INTO event (uid, source_id, date, all_day, title, notes, kind, status)"
" VALUES (?, ?, ?, 1, ?, ?, 'pack_lunch', 'confirmed')",
(scans.new_uid(), entry["menus"][0]["source_id"], day.isoformat(), f"Pack lunch — {child['display_name']}", notes)).lastrowid
conn.execute("INSERT INTO event_child VALUES (?, ?)", (event_id, child["id"]))
return True
+1
View File
@@ -26,6 +26,7 @@ STATIC_DIR = ROOT / "static"
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
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.
+22 -3
View File
@@ -6,7 +6,12 @@ Settings (environment variables):
EXTRACT_MODEL Ollama model, or a claude-* model for the Anthropic API (default nemotron-3.5-bolt:e4b)
OLLAMA_URL Ollama server (default http://localhost:11434)
EXTRACT_THINK 1 to let an Ollama model reason before answering (default 0)
EXTRACT_THINK_LEVEL low, medium or high, for models with thinking levels; only used with EXTRACT_THINK=1
(default not set: plain on)
EXTRACT_TEMPERATURE Ollama sampling temperature (default 0)
EXTRACT_TOP_P, EXTRACT_TOP_K Ollama sampling top_p (0-1) and top_k (whole number) (default not set: the model's own)
EXTRACT_NUM_CTX Ollama context size in tokens; raise it if a dense page or thinking runs out of room (default 16384)
EXTRACT_KEEP_ALIVE how long Ollama keeps the model loaded after a call: 10m, 1h, 0 to unload, -1 forever (default 10m)
EXTRACT_DOUBLE_READ 1 to read every page twice and flag where the reads disagree (default 0;
in the app each source's double_read setting decides)
EXTRACT_IGNORE comma-separated labels to drop from menu items and event titles, e.g. "BHS,Milk" (default none)
@@ -40,6 +45,16 @@ MODEL = os.environ.get("EXTRACT_MODEL", "nemotron-3.5-bolt:e4b")
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/")
THINK = os.environ.get("EXTRACT_THINK") == "1"
TEMPERATURE = float(os.environ.get("EXTRACT_TEMPERATURE", "0"))
TOP_P = float(os.environ["EXTRACT_TOP_P"]) if os.environ.get("EXTRACT_TOP_P") else None
TOP_K = int(os.environ["EXTRACT_TOP_K"]) if os.environ.get("EXTRACT_TOP_K") else None
NUM_CTX = int(os.environ.get("EXTRACT_NUM_CTX") or 16384)
KEEP_ALIVE = os.environ.get("EXTRACT_KEEP_ALIVE", "").strip() or "10m"
THINK_LEVEL = os.environ.get("EXTRACT_THINK_LEVEL", "").strip().lower() or None
if THINK_LEVEL not in (None, "low", "medium", "high"):
raise SystemExit(f"EXTRACT_THINK_LEVEL must be low, medium or high, not {THINK_LEVEL!r}")
OLLAMA_THINK = THINK_LEVEL if THINK and THINK_LEVEL else THINK # what Ollama's "think" field gets
OLLAMA_OPTIONS = {"num_ctx": NUM_CTX, "temperature": TEMPERATURE,
**({"top_p": TOP_P} if TOP_P is not None else {}), **({"top_k": TOP_K} if TOP_K is not None else {})}
DOUBLE_READ = os.environ.get("EXTRACT_DOUBLE_READ") == "1"
IGNORE = {w.strip().lower() for w in os.environ.get("EXTRACT_IGNORE", "").split(",") if w.strip()}
EFFORT = "high" # Claude only
@@ -283,8 +298,9 @@ def call_claude(image, message, system, schema):
def call_ollama(image, message, system, schema):
body = {
"model": MODEL, "stream": False, "think": THINK, "format": schema,
"options": {"num_ctx": 16384, "temperature": TEMPERATURE}, # ponytail: raise num_ctx if a dense page or thinking runs out of room
"model": MODEL, "stream": False, "think": OLLAMA_THINK, "format": schema, "options": OLLAMA_OPTIONS,
# Ollama wants a number for bare seconds ("300" isn't a duration), a string for "10m".
"keep_alive": int(KEEP_ALIVE) if KEEP_ALIVE.lstrip("-").isdigit() else KEEP_ALIVE,
"messages": [{"role": "system", "content": system},
{"role": "user", "content": message, "images": [image]}],
}
@@ -489,8 +505,11 @@ def extract_image(img, hint, double_read=None, ignore=None):
return {
"model": model,
"method": method + (" x2" if double_read else ""),
"think": THINK if call is call_ollama else None,
"think": OLLAMA_THINK if call is call_ollama else None,
"temperature": TEMPERATURE if call is call_ollama else None,
"top_p": TOP_P if call is call_ollama else None,
"top_k": TOP_K if call is call_ollama else None,
"num_ctx": NUM_CTX if call is call_ollama else None,
"seconds": round(time.time() - started, 1),
"usage": {"input_tokens": tokens[0], "output_tokens": tokens[1]},
"result": result,
+8 -1
View File
@@ -88,6 +88,7 @@ def process_scan(conn, scan_id):
hint = {"year": year, "month": month, "layout": source["layout_type"], "filter": source["applies_filter"]}
ignore = extraction.IGNORE | {w.strip().lower() for w in source["ignore_labels"] if w.strip()}
runs = []
started = time.time()
for key in scan["image_keys"]:
img = extraction.load_image(config.IMAGES_DIR / key)
run = extraction.extract_image(img, hint, double_read=source["double_read"], ignore=ignore)
@@ -124,6 +125,11 @@ def process_scan(conn, scan_id):
(dates[0] if dates else None, dates[-1] if dates else None, runs[0]["model"] if runs else None,
tokens, round(sum(r["seconds"] for r in runs), 1), scan_id))
match_drafts(conn, scan_id)
events = sum(len(r["result"]["events"]) for r in runs)
menus = sum(len(r["result"]["menu_items"]) for r in runs)
log.info("scan %s (%s, %s) read in %.1fs: %d page(s), %d events, %d menu days, model %s (%s), %d tokens",
scan_id, source["name"], scan["hint_month"], time.time() - started, len(runs), events, menus,
runs[0]["model"] if runs else "-", ", ".join(dict.fromkeys(r["method"] for r in runs)) or "-", tokens)
def overlaps(a, b):
@@ -215,11 +221,12 @@ def run_worker(stop):
if job is None:
stop.wait(2)
continue
started = time.time()
try:
process_scan(conn, job["scan_id"])
status, error = "done", None
except Exception as e: # any failure marks this scan failed; the worker keeps serving the queue
log.exception("scan %s failed", job["scan_id"])
log.exception("scan %s failed after %.1fs", job["scan_id"], time.time() - started)
status, error = "failed", f"{type(e).__name__}: {e}"
with conn:
conn.execute("UPDATE job SET status = ?, error = ? WHERE id = ?", (status, error, job["id"]))
+1 -1
View File
@@ -23,7 +23,7 @@ def main(names):
cases = [GOLDEN / f"{n}.json" for n in names] or sorted(GOLDEN.glob("*.json"))
out = RUNS / datetime.now().strftime("%Y%m%d-%H%M%S")
out.mkdir(parents=True)
ollama = "" if x.MODEL.startswith("claude") else f" at {x.OLLAMA_URL}, think={x.THINK}, temperature={x.TEMPERATURE}"
ollama = "" if x.MODEL.startswith("claude") else f" at {x.OLLAMA_URL}, think={x.OLLAMA_THINK}, temperature={x.TEMPERATURE}, top_p={x.TOP_P}, top_k={x.TOP_K}, num_ctx={x.NUM_CTX}, keep_alive={x.KEEP_ALIVE}"
print(f"Model: {x.MODEL}{ollama}")
tokens_in = tokens_out = 0
for path in cases:
+1 -1
View File
@@ -158,7 +158,7 @@ def test():
days = c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": True}).json()
assert next(d for d in days if d["date"] == "2026-10-05")["picked"] is True
pack = [e for e in c.get("/api/events", params={"calendar_id": cal}).json() if e["kind"] == "pack_lunch"]
assert len(pack) == 1 and pack[0]["title"] == "Pack lunch — Sam" and pack[0]["all_day"] is True
assert len(pack) == 1 and pack[0]["title"] == "Pack lunch — Sam" and pack[0]["all_day"] is True and pack[0]["notes"] == "Pork Chopette"
c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": False})
c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": True})
pack2 = [e for e in c.get("/api/events", params={"calendar_id": cal}).json() if e["kind"] == "pack_lunch"]