6 Commits
Author SHA1 Message Date
iamdoubz 061ddb8b01 Merge pull request 'Calendar fixes' (#8) from calendar-fixes into main
Release image / image (release) Successful in 16s
Reviewed-on: #8
2026-11-09 08:12:25 -06:00
iamdoubz ef436288e6 Update changelog for 0.0.6 2026-11-09 08:11:17 -06:00
iamdoubz 1817cb50d4 Timed pack lunch with two alerts, one-tap delete, multi-day events in the list, merge consecutive days 2026-11-09 08:10:53 -06:00
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
11 changed files with 159 additions and 21 deletions

No files matched your search

+22 -1
View File
@@ -5,6 +5,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
## [Unreleased]
## [0.0.6] - 2026-11-09
### Changed
- "Pack lunch" events are 8:00–8:30 AM instead of all day, with alerts 12 hours before and when they start (instead of the calendar's usual alert). Database migration 003 moves existing ones, and the next "New and changed" download updates them on your device.
- Deleting an event takes one tap on Delete, with no second confirm button.
- A multi-day event is listed under each of its days in the Calendar list, not only its first.
- The same all-day event read on consecutive days (No School on Wed, Thu and Fri) becomes one event from the first day to the last.
## [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
@@ -56,7 +75,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.4...main
[Unreleased]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.6...main
[0.0.6]: https://git.dou.bet/iamdoubz/parentcalendar/compare/v0.0.5...v0.0.6
[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
+11 -5
View File
@@ -26,6 +26,8 @@
let events = $state<CalEvent[] | null>(null);
let editing = $state<number | 'new' | null>(null);
let newDate = $state('');
let editDay = $state(''); // a multi-day event is listed on each day; its editor opens under the one tapped
const isOpen = (e: CalEvent, day: string) => editing === e.id && editDay === day;
let error = $state('');
$effect(() => {
@@ -36,10 +38,14 @@
const colorFor = (e: CalEvent) => children.get(e.child_ids[0])?.color ?? calendar?.color ?? 'var(--primary)';
const byDay = $derived.by(() => {
const days = new Map<string, CalEvent[]>();
const last = monthEnd(month);
for (const e of events ?? []) {
const key = e.date < `${month}-01` ? `${month}-01` : e.date; // multi-day events that began last month
if (!days.has(key)) days.set(key, []);
days.get(key)!.push(e);
// A multi-day event shows on each of its days in this month.
for (let d = parse(e.date < `${month}-01` ? `${month}-01` : e.date); iso(d) <= (e.end_date ?? e.date) && iso(d) <= last; d.setDate(d.getDate() + 1)) {
const key = iso(d);
if (!days.has(key)) days.set(key, []);
days.get(key)!.push(e);
}
}
return [...days.entries()].sort();
});
@@ -156,14 +162,14 @@
{#each list as e (e.id)}
{@const KindIcon = KIND_ICONS[e.kind]}
<li class="event" style:--child={colorFor(e)}>
<button class="item" onclick={() => (editing = editing === e.id ? null : e.id)} aria-expanded={editing === e.id}>
<button class="item" onclick={() => { editing = isOpen(e, day) ? null : e.id; editDay = day; }} aria-expanded={isOpen(e, day)}>
<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]}{#if e.location} · <MapPin size={12} aria-hidden="true" /> {e.location}{/if}</span>
</span>
</button>
{#if editing === e.id}
{#if isOpen(e, day)}
<EventEditor event={e} onsave={save(e.id)} oncancel={() => (editing = null)} ondelete={remove(e.id)} />
{/if}
</li>
+1 -6
View File
@@ -35,7 +35,6 @@
child_ids: [...(initial.child_ids ?? [])],
});
let saving = $state(false);
let confirmDelete = $state(false);
const kinds = $derived(Object.entries(KINDS).filter(([k]) => k !== 'pack_lunch' || f.kind === 'pack_lunch'));
const uid = untrack(() => event?.id ?? 'new');
@@ -130,11 +129,7 @@
<button type="button" class="ghost" onclick={oncancel}>Cancel</button>
</div>
{#if ondelete}
{#if confirmDelete}
<button type="button" class="danger solid" onclick={ondelete}><Trash2 size={18} aria-hidden="true" />Delete event</button>
{:else}
<button type="button" class="danger" onclick={() => (confirmDelete = true)}><Trash2 size={18} aria-hidden="true" />Delete…</button>
{/if}
<button type="button" class="danger" onclick={ondelete}><Trash2 size={18} aria-hidden="true" />Delete</button>
{/if}
</div>
</form>
+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>
+2
View File
@@ -16,6 +16,7 @@
import PageHeader from '../lib/ui/PageHeader.svelte';
import StatusBadge from './StatusBadge.svelte';
import MenuDays from './MenuDays.svelte';
import ScanEvents from './ScanEvents.svelte';
import Review from './Review.svelte';
let { id }: { id: number } = $props();
@@ -163,6 +164,7 @@
{: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 />
+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))] };
}
}
+3 -3
View File
@@ -541,7 +541,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.
"""A pick is a 'Pack lunch' event for that child, 8:00-8:30: 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)
@@ -557,8 +557,8 @@ def set_pick(conn, child, day, picked):
" 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, notes, kind, status)"
" VALUES (?, ?, ?, 1, ?, ?, 'pack_lunch', 'confirmed')",
"INSERT INTO event (uid, source_id, date, all_day, start_time, end_time, title, notes, kind, status)"
" VALUES (?, ?, ?, 0, '08:00', '08:30', ?, ?, 'pack_lunch', 'confirmed')", # ponytail: fixed time
(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
+23 -1
View File
@@ -242,12 +242,28 @@ def cleanup(result, ignore=None):
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)]
result["events"] = merge_days([e for e in result["events"] if not ignored(e["title"], ignore)])
for m in result["menu_items"]:
m["items"] = [i for i in m["items"] if not ignored(i, ignore)]
return result
def merge_days(events):
"""The same all-day event (title and kind) on consecutive days becomes one event with an end date,
for example No School on Wed, Thu and Fri, read cell by cell. The first day's crop and text stay."""
out, last = [], {}
for e in sorted(events, key=lambda e: e.get("date") or ""):
key = (e["title"].strip().lower(), e["kind"])
prev = last.get(key)
start, prev_end = parse_date(e.get("date")), prev and parse_date(prev.get("end_date") or prev["date"])
if prev and prev.get("all_day") and e.get("all_day") and start and prev_end and start <= prev_end + timedelta(days=1):
prev["end_date"] = max(prev.get("end_date") or prev["date"], e.get("end_date") or e["date"])
continue
last[key] = e
out.append(e)
return out
def flags_for(item, page_range):
"""Code-level checks from the spec. Any flag puts the item in 'Needs a look'."""
flags = []
@@ -540,6 +556,12 @@ def selftest():
"events": [{"date": "2026-11-04", "title": "BHS", "kind": "event"},
{"date": "2026-11-04", "title": "No School", "kind": "no_school"}]}, {"bhs"})
assert r["menu_items"][0]["items"] == ["Grapes"] and [e["title"] for e in r["events"]] == ["No School"], r
ns = lambda d, **kw: {"date": d, "end_date": None, "all_day": True, "title": "No School", "kind": "no_school", **kw}
m = merge_days([ns("2026-11-27"), ns("2026-11-25"), ns("2026-11-26"), ns("2026-11-30"),
{**ns("2026-11-26"), "title": "Thanksgiving"}, ns("2026-12-01", all_day=False)])
assert [(e["title"], e["date"], e["end_date"]) for e in m] == [
("No School", "2026-11-25", "2026-11-27"), ("Thanksgiving", "2026-11-26", None),
("No School", "2026-11-30", None), ("No School", "2026-12-01", None)], m # a weekend between, or a time: separate
# Double read: agreement adds nothing, a moved date and a one-sided item are flagged
ev = lambda d, t, **kw: {"date": d, "end_date": None, "all_day": True, "start_time": None,
"end_time": None, "kind": "event", "title": t, **kw}
+10 -3
View File
@@ -3,8 +3,10 @@ from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
from icalendar import Alarm, Calendar, Event
from icalendar.prop import vDuration, vInline
PRODID = "-//ParentCalendar//ParentCalendar//EN"
PACK_LUNCH_ALARMS = (12 * 60, 0) # minutes before: the evening before, and when it starts
def utc(stamp):
@@ -44,11 +46,13 @@ def build(name, events, tz_name, alarm_minutes=None):
ev.add("description", e["notes"])
if e.get("location"):
ev.add("location", e["location"])
if alarm_minutes and e["status"] != "deleted":
alarms = PACK_LUNCH_ALARMS if e["kind"] == "pack_lunch" else (alarm_minutes,) if alarm_minutes else ()
for minutes in alarms if e["status"] != "deleted" else ():
alarm = Alarm()
alarm.add("action", "DISPLAY")
alarm.add("description", e["title"])
alarm.add("trigger", timedelta(minutes=-alarm_minutes))
# icalendar writes zero as P0D; PT0S is what Apple calendars write for "at time of event"
alarm["TRIGGER"] = vInline("PT0S") if minutes == 0 else vDuration(timedelta(minutes=-minutes))
ev.add_component(alarm)
cal.add_component(ev)
cal.add_missing_timezones()
@@ -66,6 +70,7 @@ if __name__ == "__main__":
ev(uid="c@x", title="Trunk or Treat", all_day=False, start_time="17:00", end_time="18:30", date="2026-10-29"),
ev(uid="d@x", title="PTO", all_day=False, start_time="16:00", date="2026-10-20", location="Library"),
ev(uid="e@x", title="Spirit Day", status="deleted", sequence=2),
ev(uid="f@x", title="Pack lunch", kind="pack_lunch", all_day=False, start_time="08:00", end_time="08:30", date="2026-11-11"),
], "America/Chicago", alarm_minutes=60).decode()
assert "\r\n" in out and "VERSION:2.0" in out and "X-WR-TIMEZONE:America/Chicago" in out
assert "DTSTART;VALUE=DATE:20261030" in out and "DTEND;VALUE=DATE:20261031" in out # exclusive end
@@ -78,5 +83,7 @@ if __name__ == "__main__":
assert "SUMMARY:No School\\, Fall Break" in out # commas escaped
spirit = block("e@x")
assert "STATUS:CANCELLED" in spirit and "SEQUENCE:2" in spirit and "VALARM" not in spirit
assert out.count("BEGIN:VALARM") == 4 and "TRIGGER:-PT1H" in out
assert out.count("BEGIN:VALARM") == 6 and "TRIGGER:-PT1H" in out
lunch = block("f@x")
assert "TRIGGER:-PT12H" in lunch and "TRIGGER:PT0S" in lunch and "PT1H" not in lunch, lunch
print("ics selftest ok")
@@ -0,0 +1,5 @@
-- Pack lunch events move from all day to 8:00-8:30. The new SEQUENCE and updated_at make the next
-- "New and changed" download carry the change to calendar apps.
UPDATE event SET all_day = 0, start_time = '08:00', end_time = '08:30', sequence = sequence + 1,
updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now')
WHERE kind = 'pack_lunch' AND all_day = 1;
+1 -1
View File
@@ -158,7 +158,7 @@ def test():
days = c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": True}).json()
assert next(d for d in days if d["date"] == "2026-10-05")["picked"] is True
pack = [e for e in c.get("/api/events", params={"calendar_id": cal}).json() if e["kind"] == "pack_lunch"]
assert len(pack) == 1 and pack[0]["title"] == "Pack lunch — Sam" and pack[0]["all_day"] is True and pack[0]["notes"] == "Pork Chopette"
assert len(pack) == 1 and pack[0]["title"] == "Pack lunch — Sam" and pack[0]["all_day"] is False and pack[0]["start_time"] == "08:00" 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"]