From b03251e864f323c5fdf78191a121dcc2a9c5aacd Mon Sep 17 00:00:00 2001 From: donkey-ai Date: Sun, 28 Jun 2026 23:00:31 -0400 Subject: [PATCH] feat(bal): Group D / D1 - configurable distributed calendar reminders + save-only .ics D1 - number of reminders (default 3, max 5): - new NUM_REMINDERS config in plugin_base.py - new BalSpinBox widget bound to a BalConfig - new pure helper compute_reminder_offsets(days, count): reminders spread uniformly across the check-alive period, always before the deadline (offset >= 1), at most one per available day, de-duplicated, earliest first (e.g. (30,3)->[30,16,1], (2,3)->[2,1], (1,3)->[1], (0,3)->[]) - create_alarms() rewritten to use it and emit one VALARM per offset with a DESCRIPTION reminder text - 'Number of reminders' spin box (range 1..5) added to the settings dialog and to the Reset list D1b - save-only .ics (ask where, default to Desktop): - BalCalendar.desktop_dir() helper (~/Desktop with home fallback) - open_or_save_calendar() no longer opens the file with a calendar app; it always shows a 'save as' dialog (.ics filter, default will_event.ics, starting on the Desktop) and copies the file there, then confirms. Identical behaviour on Windows/Linux/macOS. save_to_cwd -> save_ics_to. Follow-up: removed the now-unused 'Calendar App' field from the settings dialog (and from the Reset list). CALENDAR_APP config and open_with_default_app left in place (unused) to avoid unrelated changes. D2 intentionally skipped (per user request). tests/test_group_d_alarms.py: 7 new tests (NUM_REMINDERS default/change and all distribution rules). Full suite: 217 passed. --- CHANGELOG.md | 73 ++++++++++++++ bal/core/plugin_base.py | 8 ++ bal/gui/qt/calendar.py | 19 ++++ bal/gui/qt/plugin.py | 28 ++++-- bal/gui/qt/widgets.py | 183 +++++++++++++++++++++++++++++++---- tests/test_group_d_alarms.py | 111 +++++++++++++++++++++ 6 files changed, 399 insertions(+), 23 deletions(-) create mode 100644 tests/test_group_d_alarms.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b06e76..3085476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -509,3 +509,76 @@ the will views, implemented together and delivered in a single ZIP. default font size like the others. **Verification (third follow-up):** full suite `210 passed`. + +## 8. Group D / D1 - Configurable, distributed calendar reminders and save-only .ics + +**Context / request:** +The exported calendar (.ics) used to add one reminder (VALARM) for every single +day of the check-alive period (potentially hundreds), and tried to open the file +with a calendar app. Group D D1 makes the number of reminders configurable +(default 3, max 5), spreads them across the period before the deadline, and +changes the calendar button to simply SAVE the .ics file (asking the user where, +starting on the Desktop) instead of opening it. (D2 was intentionally skipped.) + +**What changed (D1 - number of reminders):** + +- `bal/core/plugin_base.py` + - New persisted config `NUM_REMINDERS = BalConfig(config, "bal_num_reminders", + 3)` (default 3). + +- `bal/gui/qt/widgets.py` + - New `BalSpinBox` widget: an integer spin box bound to a `BalConfig` + (mirrors `BalCheckBox` / `BalLineEdit`), with a clamped range. + - New pure helper `compute_reminder_offsets(days, count)`: returns the + reminder offsets (in days before the deadline) spread uniformly across the + period. Every offset is >= 1 (reminders always fall before the deadline), + at most one reminder per available day (`min(count, days)`), de-duplicated, + sorted earliest-first. Examples: `(30, 3) -> [30, 16, 1]`, + `(2, 3) -> [2, 1]`, `(1, 3) -> [1]`, `(0, 3) -> []`. + - `create_alarms()` rewritten to read `NUM_REMINDERS`, use + `compute_reminder_offsets`, and emit one VALARM per offset (with a + DESCRIPTION reminder text, previously commented out). + +- `bal/gui/qt/plugin.py` + - New "Number of reminders" spin box in the settings dialog (range 1..5, + default 3), with an explanatory tooltip, also included in the "Reset + setting" list (resets back to 3). + +**What changed (D1b - save-only .ics, ask where, default to Desktop):** + +- `bal/gui/qt/calendar.py` + - New `BalCalendar.desktop_dir()` helper returning the user's Desktop + (`~/Desktop` when present, otherwise the home directory). + +- `bal/gui/qt/widgets.py` + - `open_or_save_calendar()` no longer tries to open the file with a calendar + app. It always opens a "save as" dialog (via `getSaveFileName`) with an + `.ics` filter, default filename `will_event.ics`, starting on the Desktop, + then copies the generated file to the chosen path and shows a confirmation + message. The unused `save_to_cwd` was replaced by `save_ics_to(target)`. + - The "Calendar App" setting is left in place (now unused) as requested. + +- `tests/test_group_d_alarms.py` (new) + - Verifies `NUM_REMINDERS` default/change and all the distribution rules of + `compute_reminder_offsets` (spread, before-deadline, one-per-day cap, empty + when no room, never exceeding the requested count, single-reminder case). + +**Verification:** +- `ruff check` on changed files: no new errors (new test file is ruff-clean). +- Full test suite: `217 passed` (210 previous + 7 new Group D tests). + +**Outcome:** DONE (delivered as a ZIP for user testing before commit). + +### Group D - follow-up ("Calendar App" removed from settings) + +- `bal/gui/qt/plugin.py`: removed the "Calendar App" field from the settings + dialog (and from the "Reset setting" list). Since the calendar button now only + SAVES the .ics file (it no longer opens it with an external app), the setting + was no longer needed. The `CALENDAR_APP` config and the `open_with_default_app` + helper are left in the codebase (unused, harmless) to avoid touching unrelated + code. +- The .ics "save as" behaviour is identical on Windows, Linux and macOS (always + starts on the user's Desktop, with a home-directory fallback); no per-OS + branching. + +**Verification (follow-up):** full suite `217 passed`. diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index 794980e..a5b99e3 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -181,6 +181,14 @@ 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. + self.NUM_REMINDERS = BalConfig(config, "bal_num_reminders", 3) + self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True) self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True) self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True) diff --git a/bal/gui/qt/calendar.py b/bal/gui/qt/calendar.py index 4874b09..651f261 100644 --- a/bal/gui/qt/calendar.py +++ b/bal/gui/qt/calendar.py @@ -30,6 +30,25 @@ class BalCalendar: _logger.error(f"starting calendar app {e}") return False + @staticmethod + def desktop_dir(): + """Return the user's Desktop directory (Group D / D1b). + + Used as the initial folder of the "save .ics" dialog. On Windows this is + normally ``C:\\Users\\\\Desktop``; on Linux/macOS ``~/Desktop`` is + used when it exists. If the Desktop cannot be located the home directory + is returned as a safe fallback, so the save dialog always opens + somewhere sensible. + + Returns: + An absolute directory path (string). + """ + home = os.path.expanduser("~") + desktop = os.path.join(home, "Desktop") + if os.path.isdir(desktop): + return desktop + return home + @staticmethod def format_time(time): diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py index 564975c..7f5c923 100644 --- a/bal/gui/qt/plugin.py +++ b/bal/gui/qt/plugin.py @@ -19,7 +19,7 @@ from electrum.gui.qt.main_window import StatusBarButton from .common import * from .common import _, _logger # underscore names are not re-exported by "import *" from .common import read_QIcon_from_bytes -from .widgets import BalCheckBox, BalLineEdit, BalTextEdit +from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit from .window import BalWindow from .dialogs import BalDialog @@ -404,10 +404,17 @@ class Plugin(BalPlugin): # fields immediately become editable/read-only. heir_editable_dates = BalCheckBox(self.EDITABLE_DATES, on_multiverse_change) + # Number of reminders spin box (Group D / D1). Sets how many reminder + # alarms the exported .ics calendar event contains. Bound to the + # persisted NUM_REMINDERS config (default 3), with a range of 1..5. + heir_num_reminders = BalSpinBox(self.NUM_REMINDERS, minimum=1, maximum=5) + # Editable line/text widgets are created once and kept in named # variables so the "Reset" button (Group C / C4b) can refresh the # displayed values after resetting the underlying config. - edit_calendar_app = BalLineEdit(self.CALENDAR_APP) + # Note: the "Calendar App" field was removed (Group D follow-up) because + # the calendar button now only SAVES the .ics file instead of opening it + # with an external app, so the setting is no longer needed in the dialog. edit_event_summary = BalLineEdit(self.EVENT_SUMMARY) edit_event_description = BalTextEdit(self.EVENT_DESCRIPTION) @@ -472,10 +479,17 @@ class Plugin(BalPlugin): ) add_widget( grid, - "Calendar App", - edit_calendar_app, + "Number of reminders", + heir_num_reminders, 5, - "Default app used to open calendar", + ( + "How many reminder alarms the exported calendar (.ics) event " + "contains.\n" + "The reminders are spread across the check-alive period and " + "always fall before the delivery deadline.\n" + "If the period is shorter than the requested number, at most " + "one reminder per day is used. Range: 1 to 5 (default 3)." + ), ) add_widget( grid, @@ -558,7 +572,7 @@ class Plugin(BalPlugin): (self.HIDE_INVALIDATED, heir_hide_invalidated, "check"), (self.AUTO_SIGN, heir_auto_sign, "check"), (self.EDITABLE_DATES, heir_editable_dates, "check"), - (self.CALENDAR_APP, edit_calendar_app, "line"), + (self.NUM_REMINDERS, heir_num_reminders, "spin"), (self.EVENT_SUMMARY, edit_event_summary, "line"), (self.EVENT_DESCRIPTION, edit_event_description, "text"), ] @@ -570,6 +584,8 @@ class Plugin(BalPlugin): # value, which is harmless. if kind == "check": widget.setChecked(bool(cfg.default)) + elif kind == "spin": + widget.setValue(int(cfg.default)) elif kind == "line": widget.setText(cfg.default) elif kind == "text": diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py index 2c12af5..2843af7 100644 --- a/bal/gui/qt/widgets.py +++ b/bal/gui/qt/widgets.py @@ -23,6 +23,58 @@ from .common import _, _logger # underscore names are not re-exported by "impor from .calendar import BalCalendar +def compute_reminder_offsets(days, count): + """Return the reminder offsets (in days BEFORE the deadline) for an .ics event. + + Group D / D1. The reminders are spread uniformly across the check-alive + period and always fall *before* the delivery deadline, i.e. every returned + offset is ``>= 1`` (a reminder exactly on the deadline would be useless). + + Rules: + * ``count`` is the requested number of reminders (the settings dialog + caps it at 5, default 3). + * at most ONE reminder per available day: the effective number is + ``min(count, days)``; + * with ``days`` available days, offsets are chosen as evenly spaced + points inside ``[1, days]`` (1 = the day before the deadline, ``days`` + = the first day of the period), de-duplicated and returned sorted + descending (earliest reminder first). + + Args: + days: number of whole days between check-alive and the deadline. + count: requested number of reminders. + + Returns: + A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for + ``days=30, count=3``. Empty if there is no room for any reminder. + """ + # No room for any reminder (deadline today or already passed). + if days < 1 or count < 1: + return [] + + # Never more reminders than available days (one per day at most). + effective = min(int(count), int(days)) + + # A single reminder: put it one day before the deadline. + if effective == 1: + return [1] + + # Spread "effective" points evenly inside [1, days]. Using i/(effective-1) + # for i in 0..effective-1 gives fractions 0..1; map them onto [1, days]. + # This places the first reminder at the start of the period (offset ~days) + # and the last one one day before the deadline (offset 1). + offsets = set() + for i in range(effective): + frac = i / (effective - 1) # 0.0 .. 1.0 + # offset = days at frac 0 (start), 1 at frac 1 (just before deadline). + offset = round(days - frac * (days - 1)) + offset = max(1, min(days, offset)) + offsets.add(offset) + + # Sorted descending: earliest reminder (largest offset) first. + return sorted(offsets, reverse=True) + + class ClickableLabel(QLabel): doubleClicked = pyqtSignal() @@ -683,15 +735,45 @@ class WillSettingsWidget(QWidget): self.widgets["baltx_fees"].set_read_only(True) def create_alarms(self, alarm_start, alarm_end): - days = (alarm_end - alarm_start).days+1 + """Build the VALARM reminder blocks for the .ics event (Group D / D1). + + 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). + + Args: + alarm_start: the check-alive datetime (start of the period). + alarm_end: the delivery-time / locktime datetime (the deadline). + + Returns: + A list of .ics text lines (possibly empty) describing the VALARMs. + """ + days = (alarm_end - alarm_start).days + + # How many reminders the user asked for (default 3 if unreadable). + try: + count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get()) + except Exception: + count = 3 + + 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." + ) + lines = [] - for i in range(1, days): + for offset in offsets: lines.extend( [ "BEGIN:VALARM", - f"TRIGGER;RELATED=END:-P{i}D", + # Fire "offset" days before the event end (the deadline). + f"TRIGGER;RELATED=END:-P{offset}D", "ACTION:DISPLAY", - # f"DESCRIPTION:{self.bal_window.bal_plugin.ALARM_DESCRIPTION.get()}", + f"DESCRIPTION:{BalCalendar.ical_escape(description)}", "END:VALARM", ] ) @@ -704,7 +786,6 @@ class WillSettingsWidget(QWidget): threshold = self.widgets["threshold"].alarm alarm_end = BalCalendar.format_time(locktime) alarm_start = BalCalendar.format_time(threshold) - days_difference = (locktime - threshold).days 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( @@ -735,23 +816,54 @@ class WillSettingsWidget(QWidget): lines = [s.rstrip("\r\n") for s in lines] ics_content = "\r\n".join(lines) + "\r\n" + # Keep the generated .ics in a temp file; it is copied to the path the + # user picks below. self.temp_path = BalCalendar.write_temp_ics(ics_content) - opened = BalCalendar.open_with_default_app( - self.bal_window.bal_plugin.CALENDAR_APP.get(), self.temp_path + + # Group D / D1b: always ask the user WHERE to save the .ics file (the + # plugin no longer tries to open it with a calendar app). The save + # 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") + target = getSaveFileName( + parent=self.bal_window.window, + title=_("Save calendar reminder (.ics)"), + # An absolute filename makes getSaveFileName ignore its remembered + # IO_DIRECTORY and open on the Desktop instead. + filename=default_path, + filter="iCalendar (*.ics);;All files (*)", + default_extension="ics", + config=self.bal_window.window.config, ) - if opened: - _logger.info(f"File opened with default app: {self.temp_path}") - else: - export_meta_gui( - self.bal_window.window, f"will_event.ics",self.save_to_cwd - + if not target: + # User cancelled the save dialog: nothing to do. + return + try: + self.save_ics_to(target) + except Exception as save_err: + _logger.error(f"saving .ics failed: {save_err}") + self.bal_window.show_warning( + _("Could not save the calendar file: {}").format(save_err) ) + return + self.bal_window.show_message( + _("Calendar file saved to:\n{}").format(target) + ) + def save_ics_to(self, target): + """Copy the generated .ics from the temp file to ``target`` (Group D / D1b). - def save_to_cwd(self,filename="event.ics"): - target = os.path.abspath(filename) - # se il file esiste, sovrascrive - _logger.debug(f"save_to_cwd {self.temp_path},{filename}") + Overwrites the destination if it already exists. Used by + open_or_save_calendar after the user picks a save location. + + Args: + target: absolute destination path chosen by the user. + + Returns: + The destination path. + """ + _logger.debug(f"save_ics_to {self.temp_path} -> {target}") with open(self.temp_path, "rb") as src, open(target, "wb") as dst: dst.write(src.read()) return target @@ -884,6 +996,43 @@ class BalCheckBox(QCheckBox): self.stateChanged.connect(on_check) +class BalSpinBox(QSpinBox): + """Integer spin box bound to a BalConfig value (Group D / D1). + + Mirrors BalCheckBox / BalLineEdit: it shows the persisted value on creation + and writes the new value back to the config whenever the user changes it. + + Args: + variable: the BalConfig accessor to read from / write to. + minimum: smallest selectable value (default 1). + maximum: largest selectable value (default 5, used by "Number of + reminders"). + on_change: optional callback invoked after the value is persisted. + """ + + def __init__(self, variable, minimum=1, maximum=5, on_change=None): + QSpinBox.__init__(self) + self.setMinimum(minimum) + self.setMaximum(maximum) + # Coerce the stored value to int and clamp it into the allowed range, + # so a stale/invalid config value can never push the spin box out of + # bounds. + try: + current = int(variable.get()) + except Exception: + current = minimum + current = max(minimum, min(maximum, current)) + self.setValue(current) + self.on_change = on_change + + def on_value_changed(v): + variable.set(int(v)) + if self.on_change: + self.on_change() + + self.valueChanged.connect(on_value_changed) + + class WillWidget(QWidget): def __init__(self, father=None, parent=None): diff --git a/tests/test_group_d_alarms.py b/tests/test_group_d_alarms.py new file mode 100644 index 0000000..4b798f5 --- /dev/null +++ b/tests/test_group_d_alarms.py @@ -0,0 +1,111 @@ +""" +Tests for Group D / D1 (configurable, distributed calendar reminders). + +Covered behaviour: + + * the persisted ``NUM_REMINDERS`` configuration key exists and defaults to 3, + and can be changed and read back; + * ``compute_reminder_offsets(days, count)`` spreads the reminders across the + check-alive period, always BEFORE the deadline (every offset >= 1), uses at + most one reminder per available day, and never returns more reminders than + requested. + +``compute_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports +PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like +the other GUI tests. + +Run: + QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \ + python3 -m pytest tests/test_group_d_alarms.py -q +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) + +from bal.core.plugin_base import BalConfig +from bal.gui.qt.widgets import compute_reminder_offsets + + +# ------------------------------------------------------------------ # +# Mocks +# ------------------------------------------------------------------ # + +class FakeConfig: + """Minimal mock for Electrum's config object (key/value store).""" + + def __init__(self): + self._store = {} + + def get(self, key, default=None): + return self._store.get(key, default) + + def set_key(self, key, value, save=True): + self._store[key] = value + + +# ------------------------------------------------------------------ # +# NUM_REMINDERS config +# ------------------------------------------------------------------ # + +def test_num_reminders_defaults_to_three(): + """D1: the reminder count defaults to 3.""" + cfg = FakeConfig() + num = BalConfig(cfg, "bal_num_reminders", 3) + assert num.get() == 3 + + +def test_num_reminders_can_be_changed(): + """D1: the reminder count is persisted and read back.""" + cfg = FakeConfig() + num = BalConfig(cfg, "bal_num_reminders", 3) + num.set(5) + assert BalConfig(cfg, "bal_num_reminders", 3).get() == 5 + + +# ------------------------------------------------------------------ # +# compute_reminder_offsets - distribution rules +# ------------------------------------------------------------------ # + +def test_offsets_long_period_three_reminders(): + """30-day period, 3 reminders: spread out, all before the deadline.""" + offsets = compute_reminder_offsets(30, 3) + assert len(offsets) == 3 + # All strictly before the deadline (offset >= 1 means "n days before end"). + assert all(o >= 1 for o in offsets) + # Sorted earliest-first (largest offset first). + assert offsets == sorted(offsets, reverse=True) + # One reminder near the start, one near the end. + assert max(offsets) == 30 + assert min(offsets) == 1 + + +def test_offsets_capped_at_one_per_day(): + """Short period: at most one reminder per available day.""" + # 2 days but 3 requested -> only 2 reminders, one per day. + assert compute_reminder_offsets(2, 3) == [2, 1] + # 1 day but 3 requested -> a single reminder, the day before the deadline. + assert compute_reminder_offsets(1, 3) == [1] + + +def test_offsets_empty_when_no_room(): + """No reminders when there is no day before the deadline.""" + assert compute_reminder_offsets(0, 3) == [] + assert compute_reminder_offsets(-5, 3) == [] + # A non-positive count also yields nothing. + assert compute_reminder_offsets(30, 0) == [] + + +def test_offsets_never_exceed_requested_count(): + """The number of reminders never exceeds the requested count (max 5).""" + offsets = compute_reminder_offsets(100, 5) + assert len(offsets) == 5 + assert all(o >= 1 for o in offsets) + # Distinct offsets only (no duplicate alarms on the same day). + assert len(set(offsets)) == len(offsets) + + +def test_offsets_single_reminder_is_day_before_deadline(): + """A single requested reminder fires one day before the deadline.""" + assert compute_reminder_offsets(30, 1) == [1]