feat(bal): export calendar .ics as separate reminder events (v0.3.7)

- open_or_save_calendar(): emit one VEVENT per reminder instead of a single
  event with VALARM blocks. Dates come from compute_reminder_offsets(), spread
  across the check-alive period, with the LAST event one day before the
  locktime. Unique UID per event (bal-<wallet>-<offset>d) and numbered
  summaries ' (reminder N/total)'.
- Removed the now-unused create_alarms() method (no more VALARM).
- Default save filename changed from will_event.ics to BAL_will_event.ics.
- common.py: added timedelta to the datetime import.
- plugin_base.py: updated the NUM_REMINDERS comment for the new behaviour.
- tests: replaced the VALARM E1 test with test_e1_build_separate_events_for_giovanna.
- Bumped version to 0.3.7 (4 files) and added CHANGELOG entry #13.

Tests: 239 passed. ruff: no new errors.
This commit is contained in:
2026-06-28 23:01:54 -04:00
parent c963f61424
commit 50129d3b77
8 changed files with 173 additions and 98 deletions

View File

@@ -709,3 +709,50 @@ asked to translate the file *names* of two of them.
**Outcome:** DONE (documentation-only change; no plugin code touched, so the **Outcome:** DONE (documentation-only change; no plugin code touched, so the
zip-first step does not apply). zip-first step does not apply).
## 13. Calendar (.ics): separate reminder events instead of one event with alarms
**Request:** the exported `.ics` added a single calendar entry (on the locktime)
with internal VALARM reminders, which most calendars show as just one
appointment. The user asked for **N separate events** (default 3), each with its
own visible date, with the **last one one day before the inheritance locktime**.
**What changed (option A):**
- `bal/gui/qt/widgets.py`
- Rewrote `WillSettingsWidget.open_or_save_calendar()` to emit **one VEVENT
per reminder**, each placed on `locktime - offset` days, instead of one
VEVENT carrying VALARM blocks. The offsets come from the existing
`compute_reminder_offsets()`, so they are spread uniformly across the
check-alive period and the **last event is always one day before the
locktime**.
- Each event gets a **unique UID** (`bal-<wallet>-<offset>d`) so calendars do
not merge them, and its summary is suffixed with **" (reminder N/total)"**
to tell the events apart. The description still reuses `EVENT_DESCRIPTION`
with the usual `$wallet_name` / `$heirs_complete` substitutions.
- Removed the now-unused `create_alarms()` method (no more VALARM blocks).
- Changed the default save filename from `will_event.ics` to
**`BAL_will_event.ics`** (still defaulting to the Desktop).
- `bal/gui/qt/common.py`
- Added `timedelta` to the `datetime` import (needed for the per-event date
arithmetic; re-exported via the GUI star-import).
- `bal/core/plugin_base.py`
- Updated the `NUM_REMINDERS` comment to describe the new "separate events"
behaviour.
- `tests/test_group_e_mock_giovanna7.py`
- Replaced the old VALARM-shape E1 test with
`test_e1_build_separate_events_for_giovanna`, which asserts the new
structure: N distinct VEVENTs, no VALARM, unique UIDs, numbered summaries,
and the last event one day before the locktime.
**Effect:** with the default of 3 reminders over, say, a 30-day period, the
`.ics` now produces 3 separate calendar appointments (e.g. 30, 16 and 1 day
before the deadline) instead of a single one. Short periods automatically
produce fewer events (at most one per day).
**Verification:**
- `py_compile` on the changed files: OK.
- `ruff check`: no new errors.
- Full test suite: see run below.
**Outcome:** DONE (delivered as a test ZIP v0.3.7 for the user to try before
commit).

View File

@@ -1 +1 @@
0.3.6 0.3.7

View File

@@ -34,4 +34,4 @@ The plugin targets Electrum 4.7.2 (the last stable release exposing
``json_db.register_dict``) and PyQt6. ``json_db.register_dict``) and PyQt6.
""" """
__version__ = "0.3.6" __version__ = "0.3.7"

View File

@@ -91,7 +91,7 @@ class BalPlugin(BasePlugin):
""" """
_version = None _version = None
__version__ = "0.3.6" # AUTOMATICALLY GENERATED DO NOT EDIT __version__ = "0.3.7" # AUTOMATICALLY GENERATED DO NOT EDIT
# Command used to open an .ics calendar file, per operating system. # Command used to open an .ics calendar file, per operating system.
default_app = { default_app = {
@@ -181,12 +181,13 @@ class BalPlugin(BasePlugin):
# stay display-only outside the wizard unless the user opts in. # stay display-only outside the wizard unless the user opts in.
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False) self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
# NUM_REMINDERS (Group D / D1): how many reminder alarms (VALARM) the # NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
# exported .ics calendar event should contain. The reminders are spread # exported .ics calendar should contain. Each reminder becomes its own
# uniformly across the check-alive period and always fall BEFORE the # VEVENT (its own date in the calendar). The dates are spread uniformly
# delivery deadline. Default 3; the settings dialog caps it at 5 and the # across the check-alive period and the LAST one always falls one day
# alarm builder additionally limits it to at most one alarm per available # before the delivery deadline (locktime). Default 3; the settings
# day. # dialog caps it at 5 and the date builder additionally limits it to at
# most one event per available day.
self.NUM_REMINDERS = BalConfig(config, "bal_num_reminders", 3) self.NUM_REMINDERS = BalConfig(config, "bal_num_reminders", 3)
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True) self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True)

View File

@@ -22,7 +22,7 @@ import subprocess
import tempfile import tempfile
import time import time
import traceback import traceback
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from decimal import Decimal from decimal import Decimal
from functools import partial from functools import partial
from typing import Any, Callable, Mapping, Optional, Union from typing import Any, Callable, Mapping, Optional, Union

View File

@@ -738,86 +738,98 @@ class WillSettingsWidget(QWidget):
# editable outside the wizard only when the setting is ticked. # editable outside the wizard only when the setting is ticked.
self.widgets["baltx_fees"].set_read_only(not editable_dates) self.widgets["baltx_fees"].set_read_only(not editable_dates)
def create_alarms(self, alarm_start, alarm_end): def open_or_save_calendar(self):
"""Build the VALARM reminder blocks for the .ics event (Group D / D1). """Build and save an .ics calendar file with SEPARATE reminder events.
The number of reminders is read from the NUM_REMINDERS setting (default Group D / D1 (revised). Instead of a single calendar event holding
3, capped at 5 by the settings dialog). They are spread uniformly across internal VALARM reminders (which most calendars show as just one entry),
the check-alive period and always fall before the delivery deadline; if this exports N *separate* VEVENTs, one per reminder date, so the user
the period is shorter than the requested number, at most one reminder sees several distinct appointments in their calendar.
per day is produced (see compute_reminder_offsets).
Args: The number of events is read from the NUM_REMINDERS setting (default 3,
alarm_start: the check-alive datetime (start of the period). capped at 5 by the settings dialog). Their dates are computed with
alarm_end: the delivery-time / locktime datetime (the deadline). ``compute_reminder_offsets``: the offsets are spread uniformly across the
check-alive period and the LAST event always falls one day before the
delivery deadline (locktime). If the period is shorter than the
requested number of reminders, at most one event per day is produced.
Returns: Each event:
A list of .ics text lines (possibly empty) describing the VALARMs. * is placed on ``locktime - offset`` days (its own visible date);
* carries a unique UID (``bal-<wallet>-<offset>d``) so calendars do
not merge the events into one;
* has its summary suffixed with " (reminder N/total)" to tell the
events apart at a glance;
* reuses the configured EVENT_DESCRIPTION (with the usual
``$wallet_name`` / ``$heirs_complete`` substitutions).
The resulting file is written to a temp location and then copied to the
path the user picks in the save dialog (default name "BAL_will_event.ics"
on the Desktop).
""" """
days = (alarm_end - alarm_start).days now = BalCalendar.format_time(datetime.now())
# How many reminders the user asked for (default 3 if unreadable). # locktime = delivery deadline; threshold = start of the check-alive
# period. Both are datetimes exposed by the date widgets as ``.alarm``.
locktime = self.widgets["locktime"].alarm
threshold = self.widgets["threshold"].alarm
# Whole days available between check-alive and the deadline.
days = (locktime - threshold).days
# How many reminder events the user asked for (default 3 if unreadable).
try: try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get()) count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
except Exception: except Exception:
count = 3 count = 3
# Day-offsets BEFORE the deadline, e.g. [30, 16, 1]. The list always
# ends with 1 (one day before the locktime) when >= 2 reminders fit.
offsets = compute_reminder_offsets(days, count) offsets = compute_reminder_offsets(days, count)
# Reminder text shown by the calendar app when each alarm fires. # Per-event heir details and the shared description/summary templates.
description = _( heirs_details = "\r\n".join(
"BAL reminder: check in before your will is delivered to your heirs." f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}"
for heir in self.bal_window.heirs
) )
lines = []
for offset in offsets:
lines.extend(
[
"BEGIN:VALARM",
# Fire "offset" days before the event end (the deadline).
f"TRIGGER;RELATED=END:-P{offset}D",
"ACTION:DISPLAY",
f"DESCRIPTION:{BalCalendar.ical_escape(description)}",
"END:VALARM",
]
)
return lines
def open_or_save_calendar(self):
now = BalCalendar.format_time(datetime.now())
locktime = self.widgets["locktime"].alarm
threshold = self.widgets["threshold"].alarm
alarm_end = BalCalendar.format_time(locktime)
alarm_start = BalCalendar.format_time(threshold)
heirs_details = "\r\n".join(f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}" for heir in self.bal_window.heirs)
event_description = BalCalendar.ical_escape( event_description = BalCalendar.ical_escape(
f"{self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()}".replace("$wallet_name",str(self.bal_window.wallet)).replace("$heirs_complete",heirs_details) f"{self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()}"
.replace("$wallet_name", str(self.bal_window.wallet))
.replace("$heirs_complete", heirs_details)
) )
#event_description =f"{event_description}{heirs_details}" summary_base = (
uid = f"bal-{str(self.bal_window.wallet)}" f"{self.bal_window.bal_plugin.EVENT_SUMMARY.get()}"
summary = BalCalendar.ical_escape( .replace("$wallet_name", str(self.bal_window.wallet))
f"{self.bal_window.bal_plugin.EVENT_SUMMARY.get()}".replace("$wallet_name",str(self.bal_window.wallet))
) )
lines = [ lines = [
"BEGIN:VCALENDAR", "BEGIN:VCALENDAR",
"VERSION:2.0", "VERSION:2.0",
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}", f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}",
]
# One separate VEVENT per reminder offset (its own date in the calendar).
total = len(offsets)
for idx, offset in enumerate(offsets, start=1):
# The visible date of this event: "offset" days before the deadline.
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
# Suffix the summary so the N events are easy to tell apart.
summary = BalCalendar.ical_escape(
f"{summary_base} (reminder {idx}/{total})"
)
lines.extend([
"BEGIN:VEVENT", "BEGIN:VEVENT",
f"UID:{uid}", # Offset in the UID keeps each event unique (no merging).
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
f"DTSTAMP:{now}", f"DTSTAMP:{now}",
f"DTSTART:{alarm_end}", f"DTSTART:{event_dt}",
f"DTEND:{alarm_end}", f"DTEND:{event_dt}",
f"SUMMARY:{summary}", f"SUMMARY:{summary}",
f"DESCRIPTION:{event_description}", f"DESCRIPTION:{event_description}",
]
lines.extend(self.create_alarms(threshold, locktime))
lines.extend([
"END:VEVENT", "END:VEVENT",
"END:VCALENDAR",
]) ])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines] lines = [s.rstrip("\r\n") for s in lines]
ics_content = "\r\n".join(lines) + "\r\n" ics_content = "\r\n".join(lines) + "\r\n"
# Keep the generated .ics in a temp file; it is copied to the path the # Keep the generated .ics in a temp file; it is copied to the path the
@@ -829,7 +841,7 @@ class WillSettingsWidget(QWidget):
# dialog opens on the user's Desktop by default, with an .ics filter and # dialog opens on the user's Desktop by default, with an .ics filter and
# a sensible default filename. # a sensible default filename.
desktop = BalCalendar.desktop_dir() desktop = BalCalendar.desktop_dir()
default_path = os.path.join(desktop, "will_event.ics") default_path = os.path.join(desktop, "BAL_will_event.ics")
target = getSaveFileName( target = getSaveFileName(
parent=self.bal_window.window, parent=self.bal_window.window,
title=_("Save calendar reminder (.ics)"), title=_("Save calendar reminder (.ics)"),

View File

@@ -1,7 +1,7 @@
{ {
"name": "bal", "name": "bal",
"fullname": "Bitcoin After Life", "fullname": "Bitcoin After Life",
"version": "0.3.6", "version": "0.3.7",
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.", "description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
"author": "Svatantrya", "author": "Svatantrya",
"licence": "MIT", "licence": "MIT",

View File

@@ -8,7 +8,7 @@ the other ``test_group_*`` and ``test_core_*`` suites.
The four sections are: The four sections are:
* E1 - calendar / .ics: reminder-offset distribution, VALARM/TRIGGER shape, * E1 - calendar / .ics: reminder-offset distribution, separate-VEVENT shape,
description escaping and temporary .ics file creation. description escaping and temporary .ics file creation.
* E2 - inheritance / states: WillItem status transitions and heir-change * E2 - inheritance / states: WillItem status transitions and heir-change
detection for giovanna7's inheritance. detection for giovanna7's inheritance.
@@ -170,38 +170,53 @@ def test_e1_reminder_offsets_single():
assert compute_reminder_offsets(10, 1) == [1] assert compute_reminder_offsets(10, 1) == [1]
def test_e1_build_valarms_for_giovanna(): def test_e1_build_separate_events_for_giovanna():
"""Build the VALARM blocks for giovanna7 the same way create_alarms does """Build the SEPARATE reminder VEVENTs for giovanna7 the same way
(offsets -> TRIGGER lines) and verify their shape. open_or_save_calendar does (one VEVENT per offset, each on its own date)
and verify their shape.
We replicate the pure part of WillSettingsWidget.create_alarms here so the We replicate the pure part of WillSettingsWidget.open_or_save_calendar here
test stays GUI-free (no Qt widget construction), while still asserting the so the test stays GUI-free (no Qt widget construction), while still
exact iCalendar TRIGGER syntax the plugin emits. asserting the exact iCalendar structure the plugin emits: N distinct events,
unique UIDs, "(reminder N/total)" summaries, and the last event placed one
day before the locktime.
""" """
description = ( from datetime import datetime, timedelta, timezone
"BAL reminder: check in before your will is delivered to your heirs."
) # 30-day period -> offsets [30, 16, 1] (the last one = 1 day before deadline).
locktime = datetime(2026, 7, 23, 9, 0, 0, tzinfo=timezone.utc)
offsets = compute_reminder_offsets(30, 3) offsets = compute_reminder_offsets(30, 3)
total = len(offsets)
wallet = "giovanna7"
summary_base = f"BAL - Will execution of {wallet}"
lines = [] lines = ["BEGIN:VCALENDAR", "VERSION:2.0"]
for offset in offsets: for idx, offset in enumerate(offsets, start=1):
lines.extend( event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
[ summary = BalCalendar.ical_escape(f"{summary_base} (reminder {idx}/{total})")
"BEGIN:VALARM", lines.extend([
f"TRIGGER;RELATED=END:-P{offset}D", "BEGIN:VEVENT",
"ACTION:DISPLAY", f"UID:bal-{wallet}-{offset}d",
f"DESCRIPTION:{BalCalendar.ical_escape(description)}", f"DTSTART:{event_dt}",
"END:VALARM", f"DTEND:{event_dt}",
] f"SUMMARY:{summary}",
) "END:VEVENT",
])
lines.append("END:VCALENDAR")
# One full VALARM block (5 lines) per reminder offset. # One separate VEVENT per reminder offset (no VALARM blocks any more).
assert lines.count("BEGIN:VALARM") == len(offsets) assert lines.count("BEGIN:VEVENT") == len(offsets)
assert lines.count("END:VALARM") == len(offsets) assert lines.count("END:VEVENT") == len(offsets)
assert len(lines) == 5 * len(offsets) assert "BEGIN:VALARM" not in lines
# The trigger uses "days before the event END" (the deadline). # UIDs are unique per event so calendars do not merge them.
assert "TRIGGER;RELATED=END:-P30D" in lines uids = [ln for ln in lines if ln.startswith("UID:")]
assert "TRIGGER;RELATED=END:-P1D" in lines assert len(uids) == len(set(uids)) == len(offsets)
# Summaries are numbered to tell the events apart.
assert any("(reminder 1/3)" in ln for ln in lines)
assert any("(reminder 3/3)" in ln for ln in lines)
# The LAST event (offset 1) sits one day before the locktime.
last_dt = BalCalendar.format_time(locktime - timedelta(days=1))
assert f"DTSTART:{last_dt}" in lines
def test_e1_event_description_escaping(): def test_e1_event_description_escaping():