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

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