27 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
iamdoubz 588133ee5a Merge pull request 'Menu reading fixes' (#3) from menu-reading-fixes into main
Release image / image (release) Successful in 7s
Reviewed-on: #3
2026-11-06 00:36:58 -06:00
iamdoubz 1d7d422a80 Update changelog for 0.0.2 2026-11-06 00:35:42 -06:00
iamdoubz ce1eb8b201 Show food emoji in each lunch day's header 2026-11-06 00:23:06 -06:00
iamdoubz 1a61a72527 Add food emoji picker with a runnable check 2026-11-06 00:23:05 -06:00
iamdoubz c8d7a7aa80 Test menu readings and no-lunch edits after publishing 2026-11-06 00:16:55 -06:00
iamdoubz 4268e17dea Switch between menu readings in review and fix menus after publishing 2026-11-06 00:16:54 -06:00
iamdoubz 98ef76bc1e Keep a day's food when it's marked no lunch 2026-11-06 00:16:54 -06:00
iamdoubz 42d5be9dfb Save both readings of a menu day when a double read disagrees 2026-11-06 00:16:54 -06:00
iamdoubz 975913315e Drop words in a stray alphabet from extracted text 2026-11-06 00:14:27 -06:00
iamdoubz 69d6f5731c Stop week-row crops from including the next week's text 2026-11-06 00:14:27 -06:00
iamdoubz 92553ebd88 Explain Ollama connection failures and how to reach Ollama from Docker 2026-11-05 23:57:51 -06:00
28 changed files with 623 additions and 70 deletions

No files matched your search

+23
View File
@@ -10,12 +10,35 @@ HOST=127.0.0.1
PORT=8000
# Extraction model: an Ollama model, or a claude-* model (then set ANTHROPIC_API_KEY).
# In Docker, localhost is the container. Use the Ollama machine's address, or http://host.docker.internal:11434
# when Ollama runs on the Docker host (start Ollama with OLLAMA_HOST=0.0.0.0 so it accepts the connection).
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
+50 -1
View File
@@ -5,6 +5,51 @@ 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
- Food emoji in each lunch day's header, picked from the day's menu (🐔🌽🍐 for chicken, corn and pears).
- Read 1 / Read 2 in review: when a double read disagrees on a menu day, both readings are kept so you can switch between them before saving. Needs database migration 002, which runs on start.
- Menus can be fixed after publishing, from the scan page. Changes show on the Lunch tab right away.
- `npm test` in `frontend/` checks the food emoji rules.
### Changed
- Marking a menu day "No lunch this day" keeps its food, so unticking it brings the text back.
- A failed scan that can't reach Ollama now says so, and how to point `OLLAMA_URL` at it from Docker. The example settings and Compose file explain `host.docker.internal`.
### Fixed
- Week-row crops no longer include the top of the next week's text, which the model misread, for example as an extra "End of Q1" event.
- Words in a stray alphabet are dropped from what the model reads. A Sept/Oct menu came back with "выда" after the last food of every day.
## [0.0.1] - 2026-11-05
First test release: the Phase 1 MVP.
@@ -21,5 +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.1...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"]
+3
View File
@@ -7,6 +7,9 @@ services:
environment:
DATA_DIR: /data
HOST: 0.0.0.0
# Lets OLLAMA_URL=http://host.docker.internal:11434 reach an Ollama running on this machine (Linux too).
extra_hosts:
- "host.docker.internal:host-gateway"
# Only this machine can reach it; put nginx (HTTPS) in front.
ports:
- "127.0.0.1:8000:8000"
+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 -1
View File
@@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
"build": "vite build",
"test": "node src/lib/foodEmoji.check.ts"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "7.3.1",
+4 -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;
@@ -25,12 +25,14 @@ export interface CalEvent {
export interface MenuItem {
id: number; scan_id: number; source_id: number; date: string; items: string[]; no_lunch: boolean;
page: number | null; cell_bbox: number[] | null; flags: string[]; source_name?: string;
reads: MenuRead[] | null; // both readings, when a double read disagreed
}
export interface MenuRead { items: string[]; no_lunch: boolean }
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 {
+35
View File
@@ -0,0 +1,35 @@
// Run: npm test (Node 22.18+ runs TypeScript directly)
import assert from 'node:assert/strict';
import { foodEmoji } from './foodEmoji.ts';
const one = (food: string) => foodEmoji([food])[0];
assert.deepEqual(foodEmoji(['Chicken', 'Corn', 'Pears']), ['🐔', '🌽', '🍐']);
// Dishes win over the ingredients in their names
assert.equal(one('Corn Dog'), '🌭');
assert.equal(one('Mini Corn Dogs'), '🌭');
assert.equal(one('Popcorn Chicken'), '🐔');
assert.equal(one('Orange Chicken'), '🐔');
assert.equal(one('Chicken Alfredo'), '🍝');
assert.equal(one('Pepperoni Pizza'), '🍕');
assert.equal(one('Cheeseburger on WG Bun'), '🍔');
assert.equal(one('Mac-n-Cheese'), '🧀');
assert.equal(one('Broccoli and Cheese'), '🥦');
assert.equal(one('Chx&Veg WG Egg Roll'), '🥟');
assert.equal(one('Beef Burrito'), '🌯');
// Similar words don't collide
assert.equal(one('Pineapple Tidbits'), '🍍');
assert.equal(one('Whole Red Apple'), '🍎');
assert.equal(one('Watermelon'), '🍉');
assert.equal(one('Potato Sticks'), '🍟');
assert.equal(one('Tater-Tots'), '🍟');
assert.equal(one("Seas'd Potato Cubes"), '🥔');
assert.equal(one('Green Beans'), '🫛');
assert.equal(one('Refried Beans'), '🫘');
assert.equal(one('Bell Pepper strips'), '🫑');
assert.equal(one('Mandarin Oranges'), '🍊');
// Foods with no rule add nothing; repeats and overflow are dropped
assert.deepEqual(foodEmoji(['WG bun', 'Ranch', 'Marinara Cup']), []);
assert.deepEqual(foodEmoji(['Diced Pears', 'Whole Pear']), ['🍐']);
assert.equal(foodEmoji(['Pizza', 'Corn', 'Peas', 'Apple', 'Banana', 'Grapes']).length, 5);
console.log('foodEmoji check ok');
+77
View File
@@ -0,0 +1,77 @@
// Emoji for a day's lunch menu, picked by keyword. Each food takes its first matching rule, so dishes come
// before the ingredients in their names: "Corn Dog" is a hot dog, "Orange Chicken" is chicken, "Chicken Alfredo" is pasta.
// ponytail: keyword list tuned on our school's menus; add a rule when a food shows up with no emoji.
const RULES: [RegExp, string][] = [
// Dishes
[/pizza|fiestada/, '🍕'],
[/corn ?dog|hot ?dog|sausage/, '🌭'],
[/burger/, '🍔'],
[/rib(let)?s?\b/, '🍖'],
[/burrito|crispito/, '🌯'],
[/taco|chalupa|nacho/, '🌮'],
[/egg ?roll|dumpling|pot ?sticker/, '🥟'],
[/spaghetti|pasta|alfredo|lasagn|noodle|ravioli/, '🍝'],
[/mac\b|mac-n|macaroni/, '🧀'],
[/\bsub\b|sandwich|wrap\b/, '🥪'],
[/french toast|pancake/, '🥞'],
[/waffle/, '🧇'],
[/\brice\b/, '🍚'],
[/soup|stew/, '🍲'],
[/breadstick|bosco|garlic knot|\brolls?\b|biscuit/, '🥖'],
[/fries|\btots?\b|tater|totcho|wedges|potato stick|hash ?brown/, '🍟'],
[/potato/, '🥔'],
[/salad/, '🥗'],
// Meat and fish
[/chicken|\bchx\b|drummies|nugget/, '🐔'],
[/turkey/, '🦃'],
[/pork|\bham\b|bacon/, '🐷'],
[/beef|steak|meatball/, '🐮'],
[/fish|tuna|salmon/, '🐟'],
[/shrimp/, '🍤'],
[/\beggs?\b/, '🥚'],
// Vegetables
[/\bcorn\b/, '🌽'],
[/carrot/, '🥕'],
[/broccoli|vegetable|veggie/, '🥦'],
[/cucumber|pickle/, '🥒'],
[/bell pepper|\bpeppers?\b/, '🫑'],
[/tomato/, '🍅'],
[/\bpeas\b|green beans?/, '🫛'],
[/beans/, '🫘'],
// Fruit
[/pineapple/, '🍍'],
[/watermelon/, '🍉'],
[/melon|cantaloupe/, '🍈'],
[/apple/, '🍎'],
[/pears?\b/, '🍐'],
[/peach/, '🍑'],
[/orange|mandarin|clementine/, '🍊'],
[/banana/, '🍌'],
[/grape/, '🍇'],
[/strawberr/, '🍓'],
[/blueberr/, '🫐'],
[/cherr|mixed fruit|fruit cup/, '🍒'],
[/kiwi/, '🥝'],
[/mango/, '🥭'],
// Everything else
[/cheese/, '🧀'],
[/popcorn/, '🍿'],
[/pretzel/, '🥨'],
[/cookie/, '🍪'],
[/muffin|cupcake/, '🧁'],
[/donut|doughnut/, '🍩'],
[/ice cream/, '🍨'],
[/milk/, '🥛'],
[/juice/, '🧃'],
];
/** Up to `max` distinct emoji for a day's foods, in menu order. Foods with no rule add nothing. */
export function foodEmoji(items: string[], max = 5): string[] {
const out: string[] = [];
for (const item of items) {
const food = item.toLowerCase();
const hit = RULES.find(([re]) => re.test(food));
if (hit && !out.includes(hit[1])) out.push(hit[1]);
}
return out.slice(0, max);
}
+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; }
+19 -4
View File
@@ -10,7 +10,8 @@
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';
@@ -30,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;
@@ -126,14 +128,20 @@
<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">{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. -->
{#if !d.greyed}<span class="emoji" aria-hidden="true">{foodEmoji(d.menus.flatMap((m) => m.items)).join('')}</span>{/if}
</span>
<span class="visually-hidden">{date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}</span>
{#if d.warning}<span class="badge warn"><TriangleAlert size={12} aria-hidden="true" />{d.warning}</span>{/if}
</div>
{#if d.greyed}
<p class="closed-note"><CalendarX size={18} aria-hidden="true" />{d.closed ?? 'No lunch this day'}</p>
@@ -172,8 +180,15 @@
.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; }
.emoji { font-size: var(--fs-lg); line-height: 1; letter-spacing: 2px; white-space: nowrap; }
.dow { font-size: var(--fs-sm); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-2); }
.dnum { font-size: var(--fs-xl); font-weight: 750; }
.food { display: flex; flex-direction: column; gap: 2px; font-size: var(--fs-sm); color: var(--text); }
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import Check from '@lucide/svelte/icons/check';
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
import { untrack } from 'svelte';
import type { MenuItem, MenuRead } from '../lib/api';
import { dayLabel } from '../lib/dates';
let { item, onsave }: { item: MenuItem; onsave: (items: string[], noLunch: boolean) => Promise<void> } = $props();
// The form edits its own copy; picking a reading replaces the copy with that reading.
let text = $state(untrack(() => item.items.join('\n')));
let noLunch = $state(untrack(() => item.no_lunch));
let saving = $state(false);
const lines = $derived(text.split('\n').map((s) => s.trim()).filter(Boolean));
const shows = (r: MenuRead) => r.no_lunch === noLunch && r.items.join('\n') === lines.join('\n');
function pick(r: MenuRead) {
text = r.items.join('\n');
noLunch = r.no_lunch;
}
async function submit(e: SubmitEvent) {
e.preventDefault();
saving = true;
try {
await onsave(lines, noLunch);
} finally {
saving = false;
}
}
</script>
<li class="card day">
<form class="stack" onsubmit={submit}>
<div class="row spread">
<span class="strong">{dayLabel(item.date)}</span>
{#each item.flags as flag}<span class="badge warn"><TriangleAlert size={12} aria-hidden="true" />{flag}</span>{/each}
</div>
{#if item.reads}
<!-- The double read disagreed: both readings are kept, so you can compare them before saving. -->
<div class="reads" role="group" aria-label={`Readings of ${dayLabel(item.date)}`}>
{#each item.reads as r, i}
<button type="button" class="sm" aria-pressed={shows(r)} onclick={() => pick(r)}>
{#if shows(r)}<Check size={16} aria-hidden="true" />{/if}Read {i + 1}
</button>
{/each}
</div>
{/if}
<textarea bind:value={text} aria-label={`Menu for ${dayLabel(item.date)}`} rows={Math.max(2, lines.length)}></textarea>
<div class="row spread">
<label class="check small"><input type="checkbox" bind:checked={noLunch} /> No lunch this day</label>
<button class="sm" type="submit" disabled={saving}>Save</button>
</div>
</form>
</li>
<style>
textarea { min-height: 72px; }
.reads { display: flex; gap: var(--s2); }
.reads button { flex: 1; }
</style>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import Utensils from '@lucide/svelte/icons/utensils';
import { api, type MenuItem, type Scan } from '../lib/api';
import { toast } from '../lib/state.svelte';
import { dayLabel } from '../lib/dates';
import MenuDay from './MenuDay.svelte';
// A scan's menu days, editable in review and after publishing.
let { scan = $bindable() }: { scan: Scan } = $props();
const flagged = $derived(scan.menu_items.filter((m) => m.flags.length).length);
const published = $derived(scan.status === 'published');
async function save(m: MenuItem, items: string[], noLunch: boolean) {
try {
await api(`/menu_items/${m.id}`, { method: 'PATCH', json: { items, no_lunch: noLunch } });
scan = await api<Scan>(`/scans/${scan.id}`);
toast(`Saved ${dayLabel(m.date)}`);
} catch (e: any) {
toast(e.message);
}
}
</script>
<section class="section">
<h2 class="section-title">
<Utensils size={16} aria-hidden="true" />{published ? 'Fix the menu' : 'Menu'}
<span class="count">{scan.menu_items.length} days{flagged ? `, ${flagged} need a look` : ''}</span>
</h2>
<p class="hint">
One food per line.
{published ? 'Changes show on the Lunch tab as soon as you save.' : 'Your kids pick lunch days on the Lunch tab after you publish.'}
</p>
<ul class="days">
{#each scan.menu_items as m (m.id)}
<MenuDay item={m} onsave={(items, noLunch) => save(m, items, noLunch)} />
{/each}
</ul>
</section>
<style>
.days { display: grid; gap: var(--s2); grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
</style>
+4 -35
View File
@@ -13,17 +13,17 @@
import Sparkles from '@lucide/svelte/icons/sparkles';
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
import Undo2 from '@lucide/svelte/icons/undo-2';
import Utensils from '@lucide/svelte/icons/utensils';
import X from '@lucide/svelte/icons/x';
import ZoomIn from '@lucide/svelte/icons/zoom-in';
import ZoomOut from '@lucide/svelte/icons/zoom-out';
import type { Component } from 'svelte';
import { api, KINDS, type CalEvent, type EventValues as Values, type Kind, type MenuItem, type Scan } from '../lib/api';
import { api, KINDS, type CalEvent, type EventValues as Values, type Kind, type Scan } from '../lib/api';
import { app, toast } from '../lib/state.svelte';
import { dayLabel, monthsBetween, whenLabel } from '../lib/dates';
import { monthsBetween, whenLabel } from '../lib/dates';
import { KIND_ICONS } from '../lib/icons';
import Segmented from '../lib/ui/Segmented.svelte';
import EventEditor from './EventEditor.svelte';
import MenuDays from './MenuDays.svelte';
import MonthGrid from './MonthGrid.svelte';
let { scan = $bindable() }: { scan: Scan } = $props();
@@ -59,7 +59,6 @@
);
const children = $derived(new Map(app.household!.children.map((c) => [c.id, c])));
const colorFor = (e: CalEvent) => children.get(e.child_ids[0])?.color ?? 'var(--primary)';
const flaggedMenu = $derived(scan.menu_items.filter((m) => m.flags.length).length);
async function refresh() {
scan = await api<Scan>(`/scans/${scan.id}`);
@@ -113,12 +112,6 @@
view = 'list';
}
async function saveMenu(m: MenuItem, text: string, noLunch: boolean) {
const items = text.split('\n').map((s) => s.trim()).filter(Boolean);
await run(() => api(`/menu_items/${m.id}`, { method: 'PATCH', json: { items, no_lunch: noLunch } }));
toast(`Saved ${dayLabel(m.date)}`);
}
async function publish() {
busy = true;
try {
@@ -289,29 +282,7 @@
{@render collapsed('unchanged', 'Unchanged', CheckCheck, unchanged)}
{@render collapsed('excluded', 'Not for this child, or unchecked', EyeOff, excluded)}
{#if scan.menu_items.length}
<section class="section">
<h2 class="section-title"><Utensils size={16} aria-hidden="true" />Menu <span class="count">{scan.menu_items.length} days{flaggedMenu ? `, ${flaggedMenu} need a look` : ''}</span></h2>
<p class="hint">One food per line. Your kids pick lunch days on the Lunch tab after you publish.</p>
<ul class="menu-days">
{#each scan.menu_items as m (m.id)}
<li class="card menu-day">
<form class="stack" onsubmit={(ev) => { ev.preventDefault(); const fd = new FormData(ev.currentTarget as HTMLFormElement); saveMenu(m, String(fd.get('items') ?? ''), fd.get('no_lunch') === 'on'); }}>
<div class="row spread">
<span class="strong">{dayLabel(m.date)}</span>
{#each m.flags as flag}<span class="badge warn"><TriangleAlert size={12} aria-hidden="true" />{flag}</span>{/each}
</div>
<textarea name="items" aria-label={`Menu for ${dayLabel(m.date)}`} rows={Math.max(2, m.items.length)}>{m.items.join('\n')}</textarea>
<div class="row spread">
<label class="check small"><input type="checkbox" name="no_lunch" checked={m.no_lunch} /> No lunch this day</label>
<button class="sm" type="submit">Save</button>
</div>
</form>
</li>
{/each}
</ul>
</section>
{/if}
{#if scan.menu_items.length}<MenuDays bind:scan />{/if}
{/if}
<div class="publish-bar">
@@ -380,8 +351,6 @@
.disclosure :global(.chev) { transition: transform var(--dur) var(--ease); transform: rotate(-90deg); }
.disclosure[aria-expanded='true'] :global(.chev) { transform: none; }
.menu-days { display: grid; gap: var(--s2); grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
.menu-day textarea { min-height: 72px; }
.publish-bar {
position: sticky; bottom: calc(var(--nav-h) + var(--s2) + env(safe-area-inset-bottom)); z-index: var(--z-sticky);
+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>
+63 -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';
@@ -12,6 +15,8 @@
import { monthLabel } from '../lib/dates';
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();
@@ -19,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 {
@@ -74,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">
@@ -118,6 +144,28 @@
<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 />
<div class="discard">
@@ -139,5 +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)
+12 -6
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")),
@@ -476,8 +477,9 @@ class MenuItemIn(BaseModel):
@app.patch("/api/menu_items/{item_id}")
def edit_menu_item(item_id: int, body: MenuItemIn, conn: DB):
"""Fix a misread menu day, in review or later."""
items = [] if body.no_lunch else [i.strip() for i in body.items if i.strip()]
"""Fix a misread menu day, in review or after publishing: menus never reach a calendar file, so the
lunch screen shows the change right away. The food is kept when no_lunch is set, so unticking it brings it back."""
items = [i.strip() for i in body.items if i.strip()]
with conn:
found(conn.execute("UPDATE menu_item SET items = ?, no_lunch = ?, flags = '[]' WHERE id = ? RETURNING id",
(json.dumps(items), body.no_lunch, item_id)).fetchone(), "menu item")
@@ -540,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"]):
@@ -547,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.
+1 -1
View File
@@ -7,7 +7,7 @@ from parentcalendar import config
MIGRATIONS = Path(__file__).parent / "migrations"
JSON_COLUMNS = {"source_ids", "ignore_labels", "image_keys", "items", "flags", "cell_bbox", "before", "after",
"child_ids"} # child_ids comes from json_group_array() subqueries
"child_ids", "reads"} # child_ids comes from json_group_array() subqueries
BOOL_COLUMNS = {"double_read", "all_day", "applies", "include", "no_lunch"}
+70 -8
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)
@@ -20,6 +25,8 @@ import os
import re
import sys
import time
import unicodedata
import urllib.error
import urllib.request
from collections import Counter
from datetime import date, datetime, timedelta
@@ -38,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
@@ -192,14 +209,38 @@ def iso(s):
return s
def script(word):
"""The alphabet of a word's letters (LATIN, CYRILLIC, CJK, ...), or None for numbers, symbols and mixes."""
names = {unicodedata.name(c, "").split(" ")[0] for c in word if c.isalpha()}
return names.pop() if len(names) == 1 else None
def drop_stray_scripts(result):
"""A small model sometimes invents a word in another alphabet and repeats it down a row (Cyrillic
"выда" after the last food of every day). Drop words in an alphabet that is under 10% of the page's words."""
# ponytail: share-of-words heuristic; a short flyer with one real title in a second alphabet loses it
words = [w for e in result["events"] for w in e["title"].split()] + [w for m in result["menu_items"] for i in m["items"] for w in i.split()]
counts = Counter(script(w) for w in words)
counts.pop(None, None)
stray = {s for s, n in counts.items() if n < 0.1 * sum(counts.values())}
if not stray:
return
keep = lambda text: " ".join(w for w in text.split() if script(w) not in stray)
for e in result["events"]:
e["title"] = keep(e["title"])
for m in result["menu_items"]:
m["items"] = [k for k in map(keep, m["items"]) if k]
def cleanup(result, ignore=None):
"""Deterministic tidy-up before validation: pad dates, drop events with no real title,
and drop labels (grade ranges and the ignore list) from event titles and menu items."""
"""Deterministic tidy-up before validation: pad dates, drop words in a stray alphabet, drop events
with no real title, and drop labels (grade ranges and the ignore list) from event titles and menu items."""
ignore = IGNORE if ignore is None else ignore
for item in [result["date_range"]] + result["events"] + result["menu_items"]:
for key in ("date", "end_date", "start", "end"):
if item.get(key):
item[key] = iso(item[key])
drop_stray_scripts(result)
result["events"] = [e for e in result["events"] if re.sub(r"[^a-z]", "", e["title"].lower()) not in ("", "event")]
result["events"] = [e for e in result["events"] if not ignored(e["title"], ignore)]
for m in result["menu_items"]:
@@ -257,14 +298,22 @@ 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]}],
}
req = urllib.request.Request(f"{OLLAMA_URL}/api/chat", json.dumps(body).encode(),
{"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=1800) as r:
try:
r = urllib.request.urlopen(req, timeout=1800)
except urllib.error.HTTPError:
raise
except urllib.error.URLError as e: # shown on the scan page, so say what to fix
raise RuntimeError(f"Can't reach Ollama at {OLLAMA_URL} ({e.reason}). In Docker, localhost is the container "
"itself: set OLLAMA_URL to the Ollama machine's address.") from e
with r:
d = json.load(r)
if d.get("done_reason") != "stop":
raise RuntimeError(f"done_reason={d.get('done_reason')} (output may be cut off; raise num_ctx)")
@@ -299,9 +348,9 @@ def vary(img):
def read_row(call, img, header, row, number, total, hint, second=False):
"""One model call for one week row. Returns (raw output, usage, model)."""
if second: # a little more of the row above and below, and smaller
if second: # a little more of the row above (not below: that's the next week's text), and smaller
pad = (row[1] - row[0]) * 4 // 100
row = (max(0, row[0] - pad), min(img.height, row[1] + pad))
row = (max(0, row[0] - pad), row[1])
strip = grid.strip(img, header, row)
if second:
strip = vary(strip)
@@ -431,6 +480,7 @@ def merge_reads(a, b):
m.setdefault("read_flags", []).append("second read did not find this")
elif norm("".join(m["items"])) != norm("".join(o["items"])) or m["no_lunch"] != o["no_lunch"]:
m.setdefault("read_flags", []).append("second read differs: items")
m["reads"] = [{"items": r["items"], "no_lunch": r["no_lunch"]} for r in (m, o)] # review can switch
for o in menu_b.values():
o.setdefault("read_flags", []).append("only the second read found this")
a["menu_items"].append(o)
@@ -455,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,
@@ -501,6 +554,15 @@ def selftest():
"No School": ["second read did not find this"],
"Happy Halloween": ["only the second read found this"]}, flags
assert m["menu_items"][0]["read_flags"] == ["second read differs: items"]
assert [r["items"] for r in m["menu_items"][0]["reads"]] == [["Corn"], ["Corn", "Peas"]]
# A word in a stray alphabet is dropped; a page that is really bilingual keeps both
page = {"events": [], "menu_items": [{"items": ["Chicken Patty", "Corn", "Whole Orange выда"]},
{"items": ["Pizza", "Garden Salad", "Mixed Fruit", "Peas", "Milk"]}]}
drop_stray_scripts(page)
assert page["menu_items"][0]["items"] == ["Chicken Patty", "Corn", "Whole Orange"], page
page = {"events": [{"title": "Book Fair 书展"}, {"title": "Picture Day 拍照日"}], "menu_items": []}
drop_stray_scripts(page)
assert page["events"][0]["title"] == "Book Fair 书展", page
print("selftest ok")
+3 -1
View File
@@ -129,7 +129,9 @@ def find_rows(img):
header = above[0] # boxed heading row
else:
header = tighten_header(img, max(0, rows[0][0] - median // 2), rows[0][0]) # headings printed above the grid
padded = [(max(0, a - median * 3 // 100), min(h, b + median * 8 // 100)) for a, b in rows]
# Below a row's line is the next week's first line of text; a sliver of it gets read as garbage
# (half-cut letters came back as Cyrillic), so the bottom pad stays as small as the top one.
padded = [(max(0, a - median * 3 // 100), min(h, b + median * 3 // 100)) for a, b in rows]
a, _ = padded[-1]
padded[-1] = (a, min(h, rows[-1][1] + median // 2)) # text often spills below the last row
return padded, header
@@ -0,0 +1,3 @@
-- Both readings of a menu day when a double read disagreed: [{"items": [...], "no_lunch": bool}, {...}].
-- NULL when the reads agreed or the source reads once. Review lets you switch between them.
ALTER TABLE menu_item ADD COLUMN reads TEXT;
+11 -3
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)
@@ -113,9 +114,10 @@ def process_scan(conn, scan_id):
for m in r["menu_items"]:
conn.execute(
"INSERT OR REPLACE INTO menu_item (scan_id, source_id, date, items, no_lunch, page, cell_bbox,"
" confidence, flags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
" confidence, flags, reads) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(scan_id, source["id"], m["date"], json.dumps(m["items"]), m["no_lunch"], page,
json.dumps(m.get("cell_bbox")), m.get("confidence"), json.dumps(m.get("flags", []))))
json.dumps(m.get("cell_bbox")), m.get("confidence"), json.dumps(m.get("flags", [])),
json.dumps(m["reads"]) if m.get("reads") else None))
dates = sorted(d for d in dates if d)
tokens = sum(r["usage"]["input_tokens"] + r["usage"]["output_tokens"] for r in runs)
conn.execute("UPDATE scan SET status = 'parsed', error = NULL, date_start = ?, date_end = ?, model = ?,"
@@ -123,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):
@@ -214,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:
+13 -2
View File
@@ -137,9 +137,20 @@ def test():
# Lunch: menu days, greyed closures, picks as events
menu = upload(c, menu_src, png("yellow"), {"events": [], "menu_items": [
{"date": "2026-10-05", "items": ["Pork Chopette", "Corn"], "no_lunch": False, "flags": []},
{"date": "2026-10-05", "items": ["Pork Chopette", "Corn"], "no_lunch": False, "flags": [],
"reads": [{"items": ["Pork Chopette", "Corn"], "no_lunch": False},
{"items": ["Pork Chop", "Corn"], "no_lunch": False}]},
{"date": "2026-10-30", "items": [], "no_lunch": True, "flags": []}]})
assert [r["items"][0] for r in menu["menu_items"][0]["reads"]] == ["Pork Chopette", "Pork Chop"] # both reads kept
assert menu["menu_items"][1]["reads"] is None
c.post(f"/api/scans/{menu['id']}/publish")
# After publishing, a mistaken "no lunch" keeps the food, and unticking it brings the day back
item = menu["menu_items"][0]["id"]
r = c.patch(f"/api/menu_items/{item}", json={"items": ["Pork Chopette", "Corn"], "no_lunch": True}).json()
assert r["no_lunch"] is True and r["items"] == ["Pork Chopette", "Corn"], r
days = {d["date"]: d for d in c.get("/api/lunch", params={"child_id": sam, "month": "2026-10"}).json()}
assert days["2026-10-05"]["greyed"] is True
c.patch(f"/api/menu_items/{item}", json={"items": ["Pork Chopette", "Corn"], "no_lunch": False})
days = {d["date"]: d for d in c.get("/api/lunch", params={"child_id": sam, "month": "2026-10"}).json()}
assert days["2026-10-05"]["greyed"] is False and days["2026-10-30"]["greyed"] is True
assert days["2026-10-30"]["closed"] == "No School"
@@ -147,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"]