feat/ui-today-stats #6
No files matched your search
@@ -39,3 +39,6 @@ EXTRACT_MODEL=nemotron-3.5-bolt:e4b
|
||||
|
||||
# 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
|
||||
+10
-1
@@ -5,6 +5,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
@@ -48,7 +56,8 @@ 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.3...main
|
||||
[Unreleased]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.4...main
|
||||
[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
|
||||
@@ -105,6 +105,7 @@ Both include `python main.py backup <destination>`, which runs SQLite's online b
|
||||
| `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**
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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';
|
||||
@@ -20,6 +23,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 +81,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 +143,26 @@
|
||||
<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}
|
||||
{#if scan.menu_items.length}<div class="menu-fix"><MenuDays bind:scan /></div>{/if}
|
||||
{:else}
|
||||
<Review bind:scan />
|
||||
@@ -141,6 +185,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>
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -48,10 +49,14 @@ def serve():
|
||||
"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)
|
||||
|
||||
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
Reference in new issue
Block a user