Calendar fixes #8
No files matched your search
@@ -5,6 +5,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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
|
## [0.0.5] - 2026-11-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
let events = $state<CalEvent[] | null>(null);
|
let events = $state<CalEvent[] | null>(null);
|
||||||
let editing = $state<number | 'new' | null>(null);
|
let editing = $state<number | 'new' | null>(null);
|
||||||
let newDate = $state('');
|
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('');
|
let error = $state('');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -36,10 +38,14 @@
|
|||||||
const colorFor = (e: CalEvent) => children.get(e.child_ids[0])?.color ?? calendar?.color ?? 'var(--primary)';
|
const colorFor = (e: CalEvent) => children.get(e.child_ids[0])?.color ?? calendar?.color ?? 'var(--primary)';
|
||||||
const byDay = $derived.by(() => {
|
const byDay = $derived.by(() => {
|
||||||
const days = new Map<string, CalEvent[]>();
|
const days = new Map<string, CalEvent[]>();
|
||||||
|
const last = monthEnd(month);
|
||||||
for (const e of events ?? []) {
|
for (const e of events ?? []) {
|
||||||
const key = e.date < `${month}-01` ? `${month}-01` : e.date; // multi-day events that began last month
|
// A multi-day event shows on each of its days in this month.
|
||||||
if (!days.has(key)) days.set(key, []);
|
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)) {
|
||||||
days.get(key)!.push(e);
|
const key = iso(d);
|
||||||
|
if (!days.has(key)) days.set(key, []);
|
||||||
|
days.get(key)!.push(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [...days.entries()].sort();
|
return [...days.entries()].sort();
|
||||||
});
|
});
|
||||||
@@ -156,14 +162,14 @@
|
|||||||
{#each list as e (e.id)}
|
{#each list as e (e.id)}
|
||||||
{@const KindIcon = KIND_ICONS[e.kind]}
|
{@const KindIcon = KIND_ICONS[e.kind]}
|
||||||
<li class="event" style:--child={colorFor(e)}>
|
<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="kind"><KindIcon size={18} aria-hidden="true" /></span>
|
||||||
<span class="grow">
|
<span class="grow">
|
||||||
<span class="title">{e.title}</span>
|
<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 class="meta">{whenLabel(e)} · {KINDS[e.kind]}{#if e.location} · <MapPin size={12} aria-hidden="true" /> {e.location}{/if}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{#if editing === e.id}
|
{#if isOpen(e, day)}
|
||||||
<EventEditor event={e} onsave={save(e.id)} oncancel={() => (editing = null)} ondelete={remove(e.id)} />
|
<EventEditor event={e} onsave={save(e.id)} oncancel={() => (editing = null)} ondelete={remove(e.id)} />
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
child_ids: [...(initial.child_ids ?? [])],
|
child_ids: [...(initial.child_ids ?? [])],
|
||||||
});
|
});
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let confirmDelete = $state(false);
|
|
||||||
const kinds = $derived(Object.entries(KINDS).filter(([k]) => k !== 'pack_lunch' || f.kind === 'pack_lunch'));
|
const kinds = $derived(Object.entries(KINDS).filter(([k]) => k !== 'pack_lunch' || f.kind === 'pack_lunch'));
|
||||||
const uid = untrack(() => event?.id ?? 'new');
|
const uid = untrack(() => event?.id ?? 'new');
|
||||||
|
|
||||||
@@ -130,11 +129,7 @@
|
|||||||
<button type="button" class="ghost" onclick={oncancel}>Cancel</button>
|
<button type="button" class="ghost" onclick={oncancel}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
{#if ondelete}
|
{#if ondelete}
|
||||||
{#if confirmDelete}
|
<button type="button" class="danger" onclick={ondelete}><Trash2 size={18} aria-hidden="true" />Delete</button>
|
||||||
<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}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -541,7 +541,7 @@ class PicksIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def set_pick(conn, child, day, picked):
|
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.
|
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)."""
|
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)
|
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"]))
|
" 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:
|
elif not existing and picked:
|
||||||
event_id = conn.execute(
|
event_id = conn.execute(
|
||||||
"INSERT INTO event (uid, source_id, date, all_day, title, notes, kind, status)"
|
"INSERT INTO event (uid, source_id, date, all_day, start_time, end_time, title, notes, kind, status)"
|
||||||
" VALUES (?, ?, ?, 1, ?, ?, 'pack_lunch', 'confirmed')",
|
" 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
|
(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"]))
|
conn.execute("INSERT INTO event_child VALUES (?, ?)", (event_id, child["id"]))
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -242,12 +242,28 @@ def cleanup(result, ignore=None):
|
|||||||
item[key] = iso(item[key])
|
item[key] = iso(item[key])
|
||||||
drop_stray_scripts(result)
|
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 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"]:
|
for m in result["menu_items"]:
|
||||||
m["items"] = [i for i in m["items"] if not ignored(i, ignore)]
|
m["items"] = [i for i in m["items"] if not ignored(i, ignore)]
|
||||||
return result
|
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):
|
def flags_for(item, page_range):
|
||||||
"""Code-level checks from the spec. Any flag puts the item in 'Needs a look'."""
|
"""Code-level checks from the spec. Any flag puts the item in 'Needs a look'."""
|
||||||
flags = []
|
flags = []
|
||||||
@@ -540,6 +556,12 @@ def selftest():
|
|||||||
"events": [{"date": "2026-11-04", "title": "BHS", "kind": "event"},
|
"events": [{"date": "2026-11-04", "title": "BHS", "kind": "event"},
|
||||||
{"date": "2026-11-04", "title": "No School", "kind": "no_school"}]}, {"bhs"})
|
{"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
|
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
|
# 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,
|
ev = lambda d, t, **kw: {"date": d, "end_date": None, "all_day": True, "start_time": None,
|
||||||
"end_time": None, "kind": "event", "title": t, **kw}
|
"end_time": None, "kind": "event", "title": t, **kw}
|
||||||
|
|||||||
+10
-3
@@ -3,8 +3,10 @@ from datetime import date, datetime, time, timedelta, timezone
|
|||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from icalendar import Alarm, Calendar, Event
|
from icalendar import Alarm, Calendar, Event
|
||||||
|
from icalendar.prop import vDuration, vInline
|
||||||
|
|
||||||
PRODID = "-//ParentCalendar//ParentCalendar//EN"
|
PRODID = "-//ParentCalendar//ParentCalendar//EN"
|
||||||
|
PACK_LUNCH_ALARMS = (12 * 60, 0) # minutes before: the evening before, and when it starts
|
||||||
|
|
||||||
|
|
||||||
def utc(stamp):
|
def utc(stamp):
|
||||||
@@ -44,11 +46,13 @@ def build(name, events, tz_name, alarm_minutes=None):
|
|||||||
ev.add("description", e["notes"])
|
ev.add("description", e["notes"])
|
||||||
if e.get("location"):
|
if e.get("location"):
|
||||||
ev.add("location", e["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 = Alarm()
|
||||||
alarm.add("action", "DISPLAY")
|
alarm.add("action", "DISPLAY")
|
||||||
alarm.add("description", e["title"])
|
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)
|
ev.add_component(alarm)
|
||||||
cal.add_component(ev)
|
cal.add_component(ev)
|
||||||
cal.add_missing_timezones()
|
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="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="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="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()
|
], "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 "\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
|
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
|
assert "SUMMARY:No School\\, Fall Break" in out # commas escaped
|
||||||
spirit = block("e@x")
|
spirit = block("e@x")
|
||||||
assert "STATUS:CANCELLED" in spirit and "SEQUENCE:2" in spirit and "VALARM" not in spirit
|
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")
|
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;
|
||||||
@@ -158,7 +158,7 @@ def test():
|
|||||||
days = c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": True}).json()
|
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
|
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"]
|
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": False})
|
||||||
c.post("/api/lunch", json={"child_id": sam, "date": "2026-10-05", "picked": True})
|
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"]
|
pack2 = [e for e in c.get("/api/events", params={"calendar_id": cal}).json() if e["kind"] == "pack_lunch"]
|
||||||
|
|||||||
Reference in new issue
Block a user