diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b7f00b..a38b3e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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--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). diff --git a/bal/VERSION b/bal/VERSION index 449d7e7..0f82685 100644 --- a/bal/VERSION +++ b/bal/VERSION @@ -1 +1 @@ -0.3.6 +0.3.7 diff --git a/bal/__init__.py b/bal/__init__.py index d4c71ef..15145ae 100644 --- a/bal/__init__.py +++ b/bal/__init__.py @@ -34,4 +34,4 @@ The plugin targets Electrum 4.7.2 (the last stable release exposing ``json_db.register_dict``) and PyQt6. """ -__version__ = "0.3.6" +__version__ = "0.3.7" diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index 804ad5b..a97a4f1 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -91,7 +91,7 @@ class BalPlugin(BasePlugin): """ _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. default_app = { @@ -181,12 +181,13 @@ class BalPlugin(BasePlugin): # stay display-only outside the wizard unless the user opts in. self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False) - # NUM_REMINDERS (Group D / D1): how many reminder alarms (VALARM) the - # exported .ics calendar event should contain. The reminders are spread - # uniformly across the check-alive period and always fall BEFORE the - # delivery deadline. Default 3; the settings dialog caps it at 5 and the - # alarm builder additionally limits it to at most one alarm per available - # day. + # NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the + # exported .ics calendar should contain. Each reminder becomes its own + # VEVENT (its own date in the calendar). The dates are spread uniformly + # across the check-alive period and the LAST one always falls one day + # before the delivery deadline (locktime). Default 3; the settings + # 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.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True) diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py index e3ede1c..f9ef192 100644 --- a/bal/gui/qt/common.py +++ b/bal/gui/qt/common.py @@ -22,7 +22,7 @@ import subprocess import tempfile import time import traceback -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from decimal import Decimal from functools import partial from typing import Any, Callable, Mapping, Optional, Union diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py index ba479df..5b929ff 100644 --- a/bal/gui/qt/widgets.py +++ b/bal/gui/qt/widgets.py @@ -738,85 +738,97 @@ class WillSettingsWidget(QWidget): # editable outside the wizard only when the setting is ticked. self.widgets["baltx_fees"].set_read_only(not editable_dates) - def create_alarms(self, alarm_start, alarm_end): - """Build the VALARM reminder blocks for the .ics event (Group D / D1). + def open_or_save_calendar(self): + """Build and save an .ics calendar file with SEPARATE reminder events. - The number of reminders is read from the NUM_REMINDERS setting (default - 3, capped at 5 by the settings dialog). They are spread uniformly across - the check-alive period and always fall before the delivery deadline; if - the period is shorter than the requested number, at most one reminder - per day is produced (see compute_reminder_offsets). + Group D / D1 (revised). Instead of a single calendar event holding + internal VALARM reminders (which most calendars show as just one entry), + this exports N *separate* VEVENTs, one per reminder date, so the user + sees several distinct appointments in their calendar. - Args: - alarm_start: the check-alive datetime (start of the period). - alarm_end: the delivery-time / locktime datetime (the deadline). + The number of events is read from the NUM_REMINDERS setting (default 3, + capped at 5 by the settings dialog). Their dates are computed with + ``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: - A list of .ics text lines (possibly empty) describing the VALARMs. + Each event: + * is placed on ``locktime - offset`` days (its own visible date); + * carries a unique UID (``bal--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: count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get()) except Exception: 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) - # Reminder text shown by the calendar app when each alarm fires. - description = _( - "BAL reminder: check in before your will is delivered to your heirs." + # Per-event heir details and the shared description/summary templates. + 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 ) - - 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( - 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}" - uid = f"bal-{str(self.bal_window.wallet)}" - summary = BalCalendar.ical_escape( - f"{self.bal_window.bal_plugin.EVENT_SUMMARY.get()}".replace("$wallet_name",str(self.bal_window.wallet)) + summary_base = ( + f"{self.bal_window.bal_plugin.EVENT_SUMMARY.get()}" + .replace("$wallet_name", str(self.bal_window.wallet)) ) + lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}", - "BEGIN:VEVENT", - f"UID:{uid}", - f"DTSTAMP:{now}", - f"DTSTART:{alarm_end}", - f"DTEND:{alarm_end}", - f"SUMMARY:{summary}", - f"DESCRIPTION:{event_description}", ] - lines.extend(self.create_alarms(threshold, locktime)) - lines.extend([ - "END:VEVENT", - "END:VCALENDAR", - ]) + + # 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", + # Offset in the UID keeps each event unique (no merging). + f"UID:bal-{str(self.bal_window.wallet)}-{offset}d", + f"DTSTAMP:{now}", + f"DTSTART:{event_dt}", + f"DTEND:{event_dt}", + f"SUMMARY:{summary}", + f"DESCRIPTION:{event_description}", + "END:VEVENT", + ]) + + lines.append("END:VCALENDAR") lines = [s.rstrip("\r\n") for s in lines] ics_content = "\r\n".join(lines) + "\r\n" @@ -829,7 +841,7 @@ class WillSettingsWidget(QWidget): # dialog opens on the user's Desktop by default, with an .ics filter and # a sensible default filename. 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( parent=self.bal_window.window, title=_("Save calendar reminder (.ics)"), diff --git a/bal/manifest.json b/bal/manifest.json index 923a5f3..2a99daa 100644 --- a/bal/manifest.json +++ b/bal/manifest.json @@ -1,7 +1,7 @@ { "name": "bal", "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.", "author": "Svatantrya", "licence": "MIT", diff --git a/tests/test_group_e_mock_giovanna7.py b/tests/test_group_e_mock_giovanna7.py index 09a92f8..58656b3 100644 --- a/tests/test_group_e_mock_giovanna7.py +++ b/tests/test_group_e_mock_giovanna7.py @@ -8,7 +8,7 @@ the other ``test_group_*`` and ``test_core_*`` suites. 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. * E2 - inheritance / states: WillItem status transitions and heir-change detection for giovanna7's inheritance. @@ -170,38 +170,53 @@ def test_e1_reminder_offsets_single(): assert compute_reminder_offsets(10, 1) == [1] -def test_e1_build_valarms_for_giovanna(): - """Build the VALARM blocks for giovanna7 the same way create_alarms does - (offsets -> TRIGGER lines) and verify their shape. +def test_e1_build_separate_events_for_giovanna(): + """Build the SEPARATE reminder VEVENTs for giovanna7 the same way + 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 - test stays GUI-free (no Qt widget construction), while still asserting the - exact iCalendar TRIGGER syntax the plugin emits. + We replicate the pure part of WillSettingsWidget.open_or_save_calendar here + so the test stays GUI-free (no Qt widget construction), while still + 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 = ( - "BAL reminder: check in before your will is delivered to your heirs." - ) + from datetime import datetime, timedelta, timezone + + # 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) + total = len(offsets) + wallet = "giovanna7" + summary_base = f"BAL - Will execution of {wallet}" - lines = [] - for offset in offsets: - lines.extend( - [ - "BEGIN:VALARM", - f"TRIGGER;RELATED=END:-P{offset}D", - "ACTION:DISPLAY", - f"DESCRIPTION:{BalCalendar.ical_escape(description)}", - "END:VALARM", - ] - ) + lines = ["BEGIN:VCALENDAR", "VERSION:2.0"] + for idx, offset in enumerate(offsets, start=1): + event_dt = BalCalendar.format_time(locktime - timedelta(days=offset)) + summary = BalCalendar.ical_escape(f"{summary_base} (reminder {idx}/{total})") + lines.extend([ + "BEGIN:VEVENT", + f"UID:bal-{wallet}-{offset}d", + f"DTSTART:{event_dt}", + f"DTEND:{event_dt}", + f"SUMMARY:{summary}", + "END:VEVENT", + ]) + lines.append("END:VCALENDAR") - # One full VALARM block (5 lines) per reminder offset. - assert lines.count("BEGIN:VALARM") == len(offsets) - assert lines.count("END:VALARM") == len(offsets) - assert len(lines) == 5 * len(offsets) - # The trigger uses "days before the event END" (the deadline). - assert "TRIGGER;RELATED=END:-P30D" in lines - assert "TRIGGER;RELATED=END:-P1D" in lines + # One separate VEVENT per reminder offset (no VALARM blocks any more). + assert lines.count("BEGIN:VEVENT") == len(offsets) + assert lines.count("END:VEVENT") == len(offsets) + assert "BEGIN:VALARM" not in lines + # UIDs are unique per event so calendars do not merge them. + uids = [ln for ln in lines if ln.startswith("UID:")] + 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():