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.
This commit is contained in:
2026-06-28 23:00:31 -04:00
parent 9d2cbc2814
commit b03251e864
6 changed files with 399 additions and 23 deletions

View File

@@ -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`.

View File

@@ -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)

View File

@@ -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\\<name>\\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):

View File

@@ -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":

View File

@@ -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
)
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
# 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 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):

View File

@@ -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]