Menu reading fixes #3
No files matched your search
+21
-1
@@ -5,6 +5,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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 +40,6 @@ 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.2...main
|
||||
[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
|
||||
@@ -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",
|
||||
|
||||
@@ -25,7 +25,9 @@ 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[];
|
||||
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
import { api, type LunchDay } from '../lib/api';
|
||||
import { app, toast } from '../lib/state.svelte';
|
||||
import { 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';
|
||||
|
||||
@@ -133,7 +134,11 @@
|
||||
<span class="dnum tabular">{date.getDate()}</span>
|
||||
</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}
|
||||
<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>
|
||||
</div>
|
||||
{#if d.greyed}
|
||||
<p class="closed-note"><CalendarX size={18} aria-hidden="true" />{d.closed ?? 'No lunch this day'}</p>
|
||||
@@ -174,6 +179,8 @@
|
||||
.day.closed { background: var(--day-closed); box-shadow: none; }
|
||||
.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); }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { monthLabel } from '../lib/dates';
|
||||
import PageHeader from '../lib/ui/PageHeader.svelte';
|
||||
import StatusBadge from './StatusBadge.svelte';
|
||||
import MenuDays from './MenuDays.svelte';
|
||||
import Review from './Review.svelte';
|
||||
|
||||
let { id }: { id: number } = $props();
|
||||
@@ -118,6 +119,7 @@
|
||||
<a class="btn ghost" href="#/calendar"><CalendarDays size={18} aria-hidden="true" /> See the calendar</a>
|
||||
</div>
|
||||
</section>
|
||||
{#if scan.menu_items.length}<div class="menu-fix"><MenuDays bind:scan /></div>{/if}
|
||||
{:else}
|
||||
<Review bind:scan />
|
||||
<div class="discard">
|
||||
@@ -139,5 +141,6 @@
|
||||
.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); }
|
||||
.menu-fix { margin-top: var(--s6); }
|
||||
.discard { margin-top: var(--s6); padding-top: var(--s4); border-top: 1px solid var(--border); display: flex; gap: var(--s2); }
|
||||
</style>
|
||||
@@ -476,8 +476,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")
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
@@ -193,14 +194,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"]:
|
||||
@@ -307,9 +332,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)
|
||||
@@ -439,6 +464,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)
|
||||
@@ -509,6 +535,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")
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -113,9 +113,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 = ?,"
|
||||
|
||||
+12
-1
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user